diff --git a/drizzle.config.ts b/drizzle.config.ts index d537eeb..a794118 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -33,6 +33,9 @@ export default defineConfig({ './src/db/schema/pds-trust-factors.ts', './src/db/schema/pages.ts', './src/db/schema/plugins.ts', + './src/db/schema/mod-notes.ts', + './src/db/schema/topic-notices.ts', + './src/db/schema/mod-warnings.ts', ], out: './drizzle', dialect: 'postgresql', diff --git a/drizzle/0012_overconfident_jackal.sql b/drizzle/0012_overconfident_jackal.sql new file mode 100644 index 0000000..9addc6b --- /dev/null +++ b/drizzle/0012_overconfident_jackal.sql @@ -0,0 +1,55 @@ +CREATE TABLE "mod_notes" ( + "id" serial PRIMARY KEY NOT NULL, + "community_did" text NOT NULL, + "author_did" text NOT NULL, + "subject_did" text, + "subject_uri" text, + "content" text NOT NULL, + "note_type" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "subject_check" CHECK ((subject_did IS NOT NULL AND subject_uri IS NULL) OR (subject_did IS NULL AND subject_uri IS NOT NULL)) +); +--> statement-breakpoint +ALTER TABLE "mod_notes" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "topic_notices" ( + "id" serial PRIMARY KEY NOT NULL, + "community_did" text NOT NULL, + "topic_uri" text NOT NULL, + "author_did" text NOT NULL, + "notice_type" text NOT NULL, + "headline" text NOT NULL, + "body" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "dismissed_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "topic_notices" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "mod_warnings" ( + "id" serial PRIMARY KEY NOT NULL, + "community_did" text NOT NULL, + "target_did" text NOT NULL, + "moderator_did" text NOT NULL, + "warning_type" text NOT NULL, + "message" text NOT NULL, + "mod_comment" text, + "internal_note" text, + "acknowledged_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "mod_warnings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "mod_notes_community_did_idx" ON "mod_notes" USING btree ("community_did");--> statement-breakpoint +CREATE INDEX "mod_notes_author_did_idx" ON "mod_notes" USING btree ("author_did");--> statement-breakpoint +CREATE INDEX "mod_notes_subject_did_idx" ON "mod_notes" USING btree ("subject_did");--> statement-breakpoint +CREATE INDEX "mod_notes_subject_uri_idx" ON "mod_notes" USING btree ("subject_uri");--> statement-breakpoint +CREATE INDEX "mod_notes_created_at_idx" ON "mod_notes" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "topic_notices_community_did_idx" ON "topic_notices" USING btree ("community_did");--> statement-breakpoint +CREATE INDEX "topic_notices_topic_uri_idx" ON "topic_notices" USING btree ("topic_uri");--> statement-breakpoint +CREATE INDEX "topic_notices_created_at_idx" ON "topic_notices" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "mod_warnings_community_did_idx" ON "mod_warnings" USING btree ("community_did");--> statement-breakpoint +CREATE INDEX "mod_warnings_target_did_idx" ON "mod_warnings" USING btree ("target_did");--> statement-breakpoint +CREATE INDEX "mod_warnings_moderator_did_idx" ON "mod_warnings" USING btree ("moderator_did");--> statement-breakpoint +CREATE INDEX "mod_warnings_created_at_idx" ON "mod_warnings" USING btree ("created_at");--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "mod_notes" AS PERMISSIVE FOR ALL TO "barazo_app" USING (community_did = current_setting('app.current_community_did', true)) WITH CHECK (community_did = current_setting('app.current_community_did', true));--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "topic_notices" AS PERMISSIVE FOR ALL TO "barazo_app" USING (community_did = current_setting('app.current_community_did', true)) WITH CHECK (community_did = current_setting('app.current_community_did', true));--> statement-breakpoint +CREATE POLICY "tenant_isolation" ON "mod_warnings" AS PERMISSIVE FOR ALL TO "barazo_app" USING (community_did = current_setting('app.current_community_did', true)) WITH CHECK (community_did = current_setting('app.current_community_did', true)); \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..18d657b --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,4809 @@ +{ + "id": "596237b5-e58c-4184-bfa1-5d1c78d16e26", + "prevId": "518f80c8-6efa-48f3-a1c5-35a93911b0ba", + "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 + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_scope": { + "name": "pinned_scope", + "type": "text", + "primaryKey": false, + "notNull": 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_pinned_scope_idx": { + "name": "topics_pinned_scope_idx", + "columns": [ + { + "expression": "pinned_scope", + "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 + }, + "public.plugin_permissions": { + "name": "plugin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_permissions_plugin_id_plugins_id_fk": { + "name": "plugin_permissions_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permissions", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_permissions_plugin_id_permission_unique": { + "name": "plugin_permissions_plugin_id_permission_unique", + "nullsNotDistinct": false, + "columns": [ + "plugin_id", + "permission" + ] + } + }, + "policies": { + "plugin_permissions_instance_wide": { + "name": "plugin_permissions_instance_wide", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_settings_plugin_id_key_unique": { + "name": "plugin_settings_plugin_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "plugin_id", + "key" + ] + } + }, + "policies": { + "plugin_settings_instance_wide": { + "name": "plugin_settings_instance_wide", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_at": { + "name": "installed_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": { + "plugins_name_unique": { + "name": "plugins_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": { + "plugins_instance_wide": { + "name": "plugins_instance_wide", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.mod_notes": { + "name": "mod_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_did": { + "name": "subject_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note_type": { + "name": "note_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_notes_community_did_idx": { + "name": "mod_notes_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_notes_author_did_idx": { + "name": "mod_notes_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_notes_subject_did_idx": { + "name": "mod_notes_subject_did_idx", + "columns": [ + { + "expression": "subject_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_notes_subject_uri_idx": { + "name": "mod_notes_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_notes_created_at_idx": { + "name": "mod_notes_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": { + "subject_check": { + "name": "subject_check", + "value": "(subject_did IS NOT NULL AND subject_uri IS NULL) OR (subject_did IS NULL AND subject_uri IS NOT NULL)" + } + }, + "isRLSEnabled": true + }, + "public.topic_notices": { + "name": "topic_notices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_uri": { + "name": "topic_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notice_type": { + "name": "notice_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "topic_notices_community_did_idx": { + "name": "topic_notices_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_notices_topic_uri_idx": { + "name": "topic_notices_topic_uri_idx", + "columns": [ + { + "expression": "topic_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_notices_created_at_idx": { + "name": "topic_notices_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.mod_warnings": { + "name": "mod_warnings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderator_did": { + "name": "moderator_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "warning_type": { + "name": "warning_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mod_comment": { + "name": "mod_comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_note": { + "name": "internal_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_warnings_community_did_idx": { + "name": "mod_warnings_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_warnings_target_did_idx": { + "name": "mod_warnings_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_warnings_moderator_did_idx": { + "name": "mod_warnings_moderator_did_idx", + "columns": [ + { + "expression": "moderator_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_warnings_created_at_idx": { + "name": "mod_warnings_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 + } + }, + "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 c069a45..d35c2cb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1772870400000, "tag": "0011_lexicon-v030-compat", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1773062314074, + "tag": "0012_overconfident_jackal", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index d90f9d4..6d73813 100644 --- a/src/app.ts +++ b/src/app.ts @@ -35,6 +35,7 @@ import { adminSettingsRoutes } from './routes/admin-settings.js' import { reactionRoutes } from './routes/reactions.js' import { voteRoutes } from './routes/votes.js' import { moderationRoutes } from './routes/moderation.js' +import { modAnnotationRoutes } from './routes/mod-annotations.js' import { moderationQueueRoutes } from './routes/moderation-queue.js' import { searchRoutes } from './routes/search.js' import { notificationRoutes } from './routes/notifications.js' @@ -492,6 +493,7 @@ export async function buildApp(env: Env) { await app.register(reactionRoutes()) await app.register(voteRoutes()) await app.register(moderationRoutes()) + await app.register(modAnnotationRoutes()) await app.register(moderationQueueRoutes()) await app.register(searchRoutes()) await app.register(notificationRoutes()) diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 0b9e3a7..ea566c5 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -29,3 +29,6 @@ export { behavioralFlags } from './behavioral-flags.js' export { pdsTrustFactors } from './pds-trust-factors.js' export { pages } from './pages.js' export { plugins, pluginSettings, pluginPermissions } from './plugins.js' +export { modNotes } from './mod-notes.js' +export { topicNotices } from './topic-notices.js' +export { modWarnings } from './mod-warnings.js' diff --git a/src/db/schema/mod-notes.ts b/src/db/schema/mod-notes.ts new file mode 100644 index 0000000..89ca49b --- /dev/null +++ b/src/db/schema/mod-notes.ts @@ -0,0 +1,34 @@ +import { pgTable, pgPolicy, text, timestamp, index, serial, check } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const modNotes = pgTable( + 'mod_notes', + { + id: serial('id').primaryKey(), + communityDid: text('community_did').notNull(), + authorDid: text('author_did').notNull(), + subjectDid: text('subject_did'), + subjectUri: text('subject_uri'), + content: text('content').notNull(), + noteType: text('note_type', { + enum: ['note', 'warning_context'], + }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check('subject_check', sql`(subject_did IS NOT NULL AND subject_uri IS NULL) OR (subject_did IS NULL AND subject_uri IS NOT NULL)`), + index('mod_notes_community_did_idx').on(table.communityDid), + index('mod_notes_author_did_idx').on(table.authorDid), + index('mod_notes_subject_did_idx').on(table.subjectDid), + index('mod_notes_subject_uri_idx').on(table.subjectUri), + index('mod_notes_created_at_idx').on(table.createdAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() diff --git a/src/db/schema/mod-warnings.ts b/src/db/schema/mod-warnings.ts new file mode 100644 index 0000000..5f07de2 --- /dev/null +++ b/src/db/schema/mod-warnings.ts @@ -0,0 +1,34 @@ +import { pgTable, pgPolicy, text, timestamp, index, serial } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const modWarnings = pgTable( + 'mod_warnings', + { + id: serial('id').primaryKey(), + communityDid: text('community_did').notNull(), + targetDid: text('target_did').notNull(), + moderatorDid: text('moderator_did').notNull(), + warningType: text('warning_type', { + enum: ['off_topic', 'harassment', 'rule_violation', 'other', 'custom'], + }).notNull(), + message: text('message').notNull(), + modComment: text('mod_comment'), + internalNote: text('internal_note'), + acknowledgedAt: timestamp('acknowledged_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('mod_warnings_community_did_idx').on(table.communityDid), + index('mod_warnings_target_did_idx').on(table.targetDid), + index('mod_warnings_moderator_did_idx').on(table.moderatorDid), + index('mod_warnings_created_at_idx').on(table.createdAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() diff --git a/src/db/schema/moderation-actions.ts b/src/db/schema/moderation-actions.ts index 2d7d610..f9d80d7 100644 --- a/src/db/schema/moderation-actions.ts +++ b/src/db/schema/moderation-actions.ts @@ -7,7 +7,7 @@ export const moderationActions = pgTable( { id: serial('id').primaryKey(), action: text('action', { - enum: ['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban'], + enum: ['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban', 'note_created', 'warning_issued', 'notice_added', 'notice_removed'], }).notNull(), targetUri: text('target_uri'), targetDid: text('target_did'), diff --git a/src/db/schema/topic-notices.ts b/src/db/schema/topic-notices.ts new file mode 100644 index 0000000..32ce06f --- /dev/null +++ b/src/db/schema/topic-notices.ts @@ -0,0 +1,32 @@ +import { pgTable, pgPolicy, text, timestamp, index, serial } from 'drizzle-orm/pg-core' +import { sql } from 'drizzle-orm' +import { appRole } from './roles.js' + +export const topicNotices = pgTable( + 'topic_notices', + { + id: serial('id').primaryKey(), + communityDid: text('community_did').notNull(), + topicUri: text('topic_uri').notNull(), + authorDid: text('author_did').notNull(), + noticeType: text('notice_type', { + enum: ['closed', 'moved', 'outdated', 'announcement', 'custom'], + }).notNull(), + headline: text('headline').notNull(), + body: text('body'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + dismissedAt: timestamp('dismissed_at', { withTimezone: true }), + }, + (table) => [ + index('topic_notices_community_did_idx').on(table.communityDid), + index('topic_notices_topic_uri_idx').on(table.topicUri), + index('topic_notices_created_at_idx').on(table.createdAt), + pgPolicy('tenant_isolation', { + as: 'permissive', + to: appRole, + for: 'all', + using: sql`community_did = current_setting('app.current_community_did', true)`, + withCheck: sql`community_did = current_setting('app.current_community_did', true)`, + }), + ] +).enableRLS() diff --git a/src/routes/mod-annotations.ts b/src/routes/mod-annotations.ts new file mode 100644 index 0000000..444fbcd --- /dev/null +++ b/src/routes/mod-annotations.ts @@ -0,0 +1,1004 @@ +import { eq, and, desc, sql, isNull } from 'drizzle-orm' +import { requireCommunityDid } from '../middleware/community-resolver.js' +import type { FastifyPluginCallback } from 'fastify' +import { + notFound, + forbidden, + badRequest, + errorResponseSchema, +} from '../lib/api-errors.js' +import { + createModNoteSchema, + modNoteQuerySchema, + deleteModNoteSchema, + createTopicNoticeSchema, + dismissTopicNoticeSchema, + topicNoticeQuerySchema, + createWarningSchema, + warningQuerySchema, + acknowledgeWarningSchema, +} from '../validation/mod-annotations.js' +import { modNotes } from '../db/schema/mod-notes.js' +import { topicNotices } from '../db/schema/topic-notices.js' +import { modWarnings } from '../db/schema/mod-warnings.js' +import { moderationActions } from '../db/schema/moderation-actions.js' +import { createRequireModerator } from '../auth/require-moderator.js' + +// --------------------------------------------------------------------------- +// OpenAPI JSON Schema definitions +// --------------------------------------------------------------------------- + +const modNoteJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + authorDid: { type: 'string' as const }, + subjectDid: { type: ['string', 'null'] as const }, + subjectUri: { type: ['string', 'null'] as const }, + content: { type: 'string' as const }, + noteType: { type: 'string' as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +const topicNoticeJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + topicUri: { type: 'string' as const }, + authorDid: { type: 'string' as const }, + noticeType: { type: 'string' as const }, + headline: { type: 'string' as const }, + body: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + dismissedAt: { type: ['string', 'null'] as const }, + }, +} + +const warningJsonSchema = { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + targetDid: { type: 'string' as const }, + moderatorDid: { type: 'string' as const }, + warningType: { type: 'string' as const }, + message: { type: 'string' as const }, + modComment: { type: ['string', 'null'] as const }, + acknowledgedAt: { type: ['string', 'null'] as const }, + createdAt: { type: 'string' as const, format: 'date-time' as const }, + }, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function serializeModNote(row: typeof modNotes.$inferSelect) { + return { + id: row.id, + authorDid: row.authorDid, + subjectDid: row.subjectDid, + subjectUri: row.subjectUri, + content: row.content, + noteType: row.noteType, + createdAt: row.createdAt.toISOString(), + } +} + +function serializeTopicNotice(row: typeof topicNotices.$inferSelect) { + return { + id: row.id, + topicUri: row.topicUri, + authorDid: row.authorDid, + noticeType: row.noticeType, + headline: row.headline, + body: row.body, + createdAt: row.createdAt.toISOString(), + dismissedAt: row.dismissedAt?.toISOString() ?? null, + } +} + +function serializeWarning(row: typeof modWarnings.$inferSelect) { + return { + id: row.id, + targetDid: row.targetDid, + moderatorDid: row.moderatorDid, + warningType: row.warningType, + message: row.message, + modComment: row.modComment, + acknowledgedAt: row.acknowledgedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + } +} + +function encodeCursor(createdAt: string, id: number): string { + return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64') +} + +function decodeCursor(cursor: string): { createdAt: string; id: number } | null { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record< + string, + unknown + > + if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') { + return { createdAt: decoded.createdAt, id: decoded.id } + } + return null + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Mod annotation routes plugin +// --------------------------------------------------------------------------- + +export function modAnnotationRoutes(): FastifyPluginCallback { + return (app, _opts, done) => { + const { db, authMiddleware } = app + const requireModerator = createRequireModerator(db, authMiddleware, app.log) + + // ------------------------------------------------------------------- + // POST /api/mod-notes (moderator+) + // ------------------------------------------------------------------- + + app.post( + '/api/mod-notes', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'Create a moderator note on a user or post', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + subjectDid: { type: 'string' }, + subjectUri: { type: 'string' }, + content: { type: 'string', minLength: 1, maxLength: 5000 }, + }, + required: ['content'], + }, + response: { + 201: { + type: 'object', + properties: { + note: modNoteJsonSchema, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const parsed = createModNoteSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest(parsed.error.issues[0]?.message ?? 'Invalid request body') + } + + const { subjectDid, subjectUri, content } = parsed.data + + const rows = await db + .insert(modNotes) + .values({ + communityDid, + authorDid: user.did, + subjectDid: subjectDid ?? null, + subjectUri: subjectUri ?? null, + content, + noteType: 'note', + }) + .returning() + + const note = rows[0] + if (!note) { + throw badRequest('Failed to create mod note') + } + + // Audit trail + await db.insert(moderationActions).values({ + action: 'note_created', + targetUri: subjectUri ?? null, + targetDid: subjectDid ?? null, + moderatorDid: user.did, + communityDid, + reason: content.slice(0, 200), + }) + + app.log.info( + { noteId: note.id, moderatorDid: user.did, subjectDid, subjectUri }, + 'Mod note created' + ) + + return reply.status(201).send({ note: serializeModNote(note) }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/mod-notes (moderator+) + // ------------------------------------------------------------------- + + app.get( + '/api/mod-notes', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'List moderator notes (filter by subjectDid or subjectUri)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + subjectDid: { type: 'string' }, + subjectUri: { type: 'string' }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + notes: { type: 'array', items: modNoteJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + + const parsed = modNoteQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { subjectDid, subjectUri, cursor, limit } = parsed.data + const conditions = [eq(modNotes.communityDid, communityDid)] + + if (subjectDid) { + conditions.push(eq(modNotes.subjectDid, subjectDid)) + } + if (subjectUri) { + conditions.push(eq(modNotes.subjectUri, subjectUri)) + } + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${modNotes.createdAt}, ${modNotes.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = and(...conditions) + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(modNotes) + .where(whereClause) + .orderBy(desc(modNotes.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + notes: resultRows.map(serializeModNote), + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // DELETE /api/mod-notes/:id (moderator+) + // ------------------------------------------------------------------- + + app.delete( + '/api/mod-notes/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'Delete a moderator note', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const paramsParsed = deleteModNoteSchema.safeParse(request.params) + if (!paramsParsed.success) { + throw badRequest('Invalid note ID') + } + + const { id } = paramsParsed.data + + const rows = await db + .select() + .from(modNotes) + .where(and(eq(modNotes.id, id), eq(modNotes.communityDid, communityDid))) + + const note = rows[0] + if (!note) { + throw notFound('Mod note not found') + } + + await db + .delete(modNotes) + .where(and(eq(modNotes.id, id), eq(modNotes.communityDid, communityDid))) + + await db.insert(moderationActions).values({ + action: 'note_created', + targetUri: note.subjectUri, + targetDid: note.subjectDid, + moderatorDid: user.did, + communityDid, + reason: `Deleted mod note #${String(id)}`, + }) + + app.log.info( + { noteId: id, moderatorDid: user.did }, + 'Mod note deleted' + ) + + return reply.status(200).send({ success: true }) + } + ) + + // ------------------------------------------------------------------- + // POST /api/topic-notices (moderator+) + // ------------------------------------------------------------------- + + app.post( + '/api/topic-notices', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'Create a topic notice', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['topicUri', 'noticeType', 'headline'], + properties: { + topicUri: { type: 'string' }, + noticeType: { type: 'string', enum: ['closed', 'moved', 'outdated', 'announcement', 'custom'] }, + headline: { type: 'string', minLength: 1, maxLength: 200 }, + body: { type: 'string', maxLength: 2000 }, + }, + }, + response: { + 201: { + type: 'object', + properties: { + notice: topicNoticeJsonSchema, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const parsed = createTopicNoticeSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest(parsed.error.issues[0]?.message ?? 'Invalid request body') + } + + const { topicUri, noticeType, headline, body } = parsed.data + + const rows = await db + .insert(topicNotices) + .values({ + communityDid, + topicUri, + authorDid: user.did, + noticeType, + headline, + body: body ?? null, + }) + .returning() + + const notice = rows[0] + if (!notice) { + throw badRequest('Failed to create topic notice') + } + + // Audit trail + await db.insert(moderationActions).values({ + action: 'notice_added', + targetUri: topicUri, + moderatorDid: user.did, + communityDid, + reason: headline, + }) + + app.log.info( + { noticeId: notice.id, moderatorDid: user.did, topicUri }, + 'Topic notice created' + ) + + return reply.status(201).send({ notice: serializeTopicNotice(notice) }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/topic-notices (public with topicUri, moderator+ without) + // ------------------------------------------------------------------- + + app.get( + '/api/topic-notices', + { + schema: { + tags: ['Mod Annotations'], + summary: 'List topic notices (public when filtered by topicUri, moderator-only otherwise)', + querystring: { + type: 'object', + properties: { + topicUri: { type: 'string' }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + notices: { type: 'array', items: topicNoticeJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + + const parsed = topicNoticeQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { topicUri, cursor, limit } = parsed.data + + // When no topicUri is provided, require moderator auth + if (!topicUri) { + await requireModerator(request, reply) + if (reply.sent) return + } + + const conditions = [eq(topicNotices.communityDid, communityDid)] + + if (topicUri) { + conditions.push(eq(topicNotices.topicUri, topicUri)) + // Public view: only active (non-dismissed) notices + conditions.push(isNull(topicNotices.dismissedAt)) + } + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${topicNotices.createdAt}, ${topicNotices.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = and(...conditions) + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(topicNotices) + .where(whereClause) + .orderBy(desc(topicNotices.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + notices: resultRows.map(serializeTopicNotice), + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // DELETE /api/topic-notices/:id (moderator+, soft delete) + // ------------------------------------------------------------------- + + app.delete( + '/api/topic-notices/:id', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'Dismiss a topic notice (soft delete)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + response: { + 200: { + type: 'object', + properties: { + notice: topicNoticeJsonSchema, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const paramsParsed = dismissTopicNoticeSchema.safeParse(request.params) + if (!paramsParsed.success) { + throw badRequest('Invalid notice ID') + } + + const { id } = paramsParsed.data + + const rows = await db + .select() + .from(topicNotices) + .where(and(eq(topicNotices.id, id), eq(topicNotices.communityDid, communityDid))) + + const notice = rows[0] + if (!notice) { + throw notFound('Topic notice not found') + } + + const updatedRows = await db + .update(topicNotices) + .set({ dismissedAt: sql`now()` }) + .where(and(eq(topicNotices.id, id), eq(topicNotices.communityDid, communityDid))) + .returning() + + const updated = updatedRows[0] + if (!updated) { + throw notFound('Failed to dismiss topic notice') + } + + // Audit trail + await db.insert(moderationActions).values({ + action: 'notice_removed', + targetUri: notice.topicUri, + moderatorDid: user.did, + communityDid, + reason: `Dismissed notice: ${notice.headline}`, + }) + + app.log.info( + { noticeId: id, moderatorDid: user.did }, + 'Topic notice dismissed' + ) + + return reply.status(200).send({ notice: serializeTopicNotice(updated) }) + } + ) + + // ------------------------------------------------------------------- + // POST /api/warnings (moderator+) + // ------------------------------------------------------------------- + + app.post( + '/api/warnings', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'Issue a warning to a user', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + required: ['targetDid', 'warningType', 'message'], + properties: { + targetDid: { type: 'string' }, + warningType: { type: 'string', enum: ['off_topic', 'harassment', 'rule_violation', 'other', 'custom'] }, + message: { type: 'string', minLength: 1, maxLength: 2000 }, + modComment: { type: 'string', maxLength: 300 }, + internalNote: { type: 'string', maxLength: 5000 }, + }, + }, + response: { + 201: { + type: 'object', + properties: { + warning: warningJsonSchema, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const parsed = createWarningSchema.safeParse(request.body) + if (!parsed.success) { + throw badRequest(parsed.error.issues[0]?.message ?? 'Invalid request body') + } + + const { targetDid, warningType, message, modComment, internalNote } = parsed.data + + const rows = await db + .insert(modWarnings) + .values({ + communityDid, + targetDid, + moderatorDid: user.did, + warningType, + message, + modComment: modComment ?? null, + internalNote: internalNote ?? null, + }) + .returning() + + const warning = rows[0] + if (!warning) { + throw badRequest('Failed to create warning') + } + + // If internalNote provided, also create a mod note with warning_context type + if (internalNote) { + await db.insert(modNotes).values({ + communityDid, + authorDid: user.did, + subjectDid: targetDid, + content: internalNote, + noteType: 'warning_context', + }) + } + + // Audit trail + await db.insert(moderationActions).values({ + action: 'warning_issued', + targetDid, + moderatorDid: user.did, + communityDid, + reason: message.slice(0, 200), + }) + + app.log.info( + { warningId: warning.id, moderatorDid: user.did, targetDid }, + 'Warning issued' + ) + + return reply.status(201).send({ warning: serializeWarning(warning) }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/warnings (moderator+) + // ------------------------------------------------------------------- + + app.get( + '/api/warnings', + { + preHandler: [requireModerator], + schema: { + tags: ['Mod Annotations'], + summary: 'List warnings (filter by targetDid)', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + targetDid: { type: 'string' }, + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + warnings: { type: 'array', items: warningJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + + const parsed = warningQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { targetDid, cursor, limit } = parsed.data + const conditions = [eq(modWarnings.communityDid, communityDid)] + + if (targetDid) { + conditions.push(eq(modWarnings.targetDid, targetDid)) + } + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${modWarnings.createdAt}, ${modWarnings.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = and(...conditions) + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(modWarnings) + .where(whereClause) + .orderBy(desc(modWarnings.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + warnings: resultRows.map(serializeWarning), + cursor: nextCursor, + }) + } + ) + + // ------------------------------------------------------------------- + // POST /api/warnings/:id/acknowledge (authenticated, target user only) + // ------------------------------------------------------------------- + + app.post( + '/api/warnings/:id/acknowledge', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Mod Annotations'], + summary: 'Acknowledge a warning (target user only)', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + response: { + 200: { + type: 'object', + properties: { + warning: warningJsonSchema, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const paramsParsed = acknowledgeWarningSchema.safeParse(request.params) + if (!paramsParsed.success) { + throw badRequest('Invalid warning ID') + } + + const { id } = paramsParsed.data + + const rows = await db + .select() + .from(modWarnings) + .where(and(eq(modWarnings.id, id), eq(modWarnings.communityDid, communityDid))) + + const warning = rows[0] + if (!warning) { + throw notFound('Warning not found') + } + + if (warning.targetDid !== user.did) { + throw forbidden('You can only acknowledge warnings directed at you') + } + + if (warning.acknowledgedAt) { + throw badRequest('Warning has already been acknowledged') + } + + const updatedRows = await db + .update(modWarnings) + .set({ acknowledgedAt: sql`now()` }) + .where(and(eq(modWarnings.id, id), eq(modWarnings.communityDid, communityDid))) + .returning() + + const updated = updatedRows[0] + if (!updated) { + throw notFound('Failed to acknowledge warning') + } + + app.log.info( + { warningId: id, userDid: user.did }, + 'Warning acknowledged' + ) + + return reply.status(200).send({ warning: serializeWarning(updated) }) + } + ) + + // ------------------------------------------------------------------- + // GET /api/my-warnings (authenticated) + // ------------------------------------------------------------------- + + app.get( + '/api/my-warnings', + { + preHandler: [authMiddleware.requireAuth], + schema: { + tags: ['Mod Annotations'], + summary: 'List own warnings in this community', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + cursor: { type: 'string' }, + limit: { type: 'string' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + warnings: { type: 'array', items: warningJsonSchema }, + cursor: { type: ['string', 'null'] }, + }, + }, + 400: errorResponseSchema, + 401: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const communityDid = requireCommunityDid(request) + const user = request.user + if (!user) { + return reply.status(401).send({ error: 'Authentication required' }) + } + + const parsed = warningQuerySchema.safeParse(request.query) + if (!parsed.success) { + throw badRequest('Invalid query parameters') + } + + const { cursor, limit } = parsed.data + const conditions = [ + eq(modWarnings.communityDid, communityDid), + eq(modWarnings.targetDid, user.did), + ] + + if (cursor) { + const decoded = decodeCursor(cursor) + if (decoded) { + conditions.push( + sql`(${modWarnings.createdAt}, ${modWarnings.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})` + ) + } + } + + const whereClause = and(...conditions) + const fetchLimit = limit + 1 + + const rows = await db + .select() + .from(modWarnings) + .where(whereClause) + .orderBy(desc(modWarnings.createdAt)) + .limit(fetchLimit) + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + let nextCursor: string | null = null + if (hasMore) { + const lastRow = resultRows[resultRows.length - 1] + if (lastRow) { + nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id) + } + } + + return reply.status(200).send({ + warnings: resultRows.map(serializeWarning), + cursor: nextCursor, + }) + } + ) + + done() + } +} diff --git a/src/validation/mod-annotations.ts b/src/validation/mod-annotations.ts new file mode 100644 index 0000000..8376709 --- /dev/null +++ b/src/validation/mod-annotations.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' + +// --------------------------------------------------------------------------- +// Mod notes +// --------------------------------------------------------------------------- + +export const createModNoteSchema = z + .object({ + subjectDid: z.string().min(1).optional(), + subjectUri: z.string().min(1).optional(), + content: z.string().min(1).max(5000), + }) + .refine( + (data) => { + const hasDid = data.subjectDid !== undefined + const hasUri = data.subjectUri !== undefined + return (hasDid && !hasUri) || (!hasDid && hasUri) + }, + { message: 'Exactly one of subjectDid or subjectUri must be provided' }, + ) + +export const modNoteQuerySchema = z.object({ + subjectDid: z.string().optional(), + subjectUri: z.string().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) + +export const deleteModNoteSchema = z.object({ + id: z.coerce.number().int().positive(), +}) + +// --------------------------------------------------------------------------- +// Topic notices +// --------------------------------------------------------------------------- + +export const createTopicNoticeSchema = z.object({ + topicUri: z.string().min(1), + noticeType: z.enum(['closed', 'moved', 'outdated', 'announcement', 'custom']), + headline: z.string().min(1).max(200), + body: z.string().max(2000).optional(), +}) + +export const dismissTopicNoticeSchema = z.object({ + id: z.coerce.number().int().positive(), +}) + +export const topicNoticeQuerySchema = z.object({ + topicUri: z.string().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) + +// --------------------------------------------------------------------------- +// Mod warnings +// --------------------------------------------------------------------------- + +export const createWarningSchema = z.object({ + targetDid: z.string().min(1), + warningType: z.enum(['off_topic', 'harassment', 'rule_violation', 'other', 'custom']), + message: z.string().min(1).max(2000), + modComment: z.string().max(300).optional(), + internalNote: z.string().max(5000).optional(), +}) + +export const warningQuerySchema = z.object({ + targetDid: z.string().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}) + +export const acknowledgeWarningSchema = z.object({ + id: z.coerce.number().int().positive(), +}) diff --git a/src/validation/moderation.ts b/src/validation/moderation.ts index 9399739..d096eb3 100644 --- a/src/validation/moderation.ts +++ b/src/validation/moderation.ts @@ -30,7 +30,7 @@ export const unbanUserSchema = z.object({ export const moderationLogQuerySchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), - action: z.enum(['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban']).optional(), + action: z.enum(['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban', 'note_created', 'warning_issued', 'notice_added', 'notice_removed']).optional(), }) // --------------------------------------------------------------------------- diff --git a/tests/unit/db/schema/mod-notes.test.ts b/tests/unit/db/schema/mod-notes.test.ts new file mode 100644 index 0000000..45bce16 --- /dev/null +++ b/tests/unit/db/schema/mod-notes.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { modNotes } from '../../../../src/db/schema/mod-notes.js' +import { getTableName, getTableColumns } from 'drizzle-orm' + +describe('modNotes schema', () => { + it('should have the correct table name', () => { + expect(getTableName(modNotes)).toBe('mod_notes') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(modNotes) + const columnNames = Object.keys(columns) + expect(columnNames).toContain('id') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('authorDid') + expect(columnNames).toContain('subjectDid') + expect(columnNames).toContain('subjectUri') + expect(columnNames).toContain('content') + expect(columnNames).toContain('noteType') + expect(columnNames).toContain('createdAt') + }) + + it('should have id as primary key', () => { + const columns = getTableColumns(modNotes) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(modNotes) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.authorDid.notNull).toBe(true) + expect(columns.content.notNull).toBe(true) + expect(columns.noteType.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) + + it('should allow nullable subject columns', () => { + const columns = getTableColumns(modNotes) + expect(columns.subjectDid.notNull).toBe(false) + expect(columns.subjectUri.notNull).toBe(false) + }) +}) diff --git a/tests/unit/db/schema/mod-warnings.test.ts b/tests/unit/db/schema/mod-warnings.test.ts new file mode 100644 index 0000000..d7b6f38 --- /dev/null +++ b/tests/unit/db/schema/mod-warnings.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { modWarnings } from '../../../../src/db/schema/mod-warnings.js' +import { getTableName, getTableColumns } from 'drizzle-orm' + +describe('modWarnings schema', () => { + it('should have the correct table name', () => { + expect(getTableName(modWarnings)).toBe('mod_warnings') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(modWarnings) + const columnNames = Object.keys(columns) + expect(columnNames).toContain('id') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('targetDid') + expect(columnNames).toContain('moderatorDid') + expect(columnNames).toContain('warningType') + expect(columnNames).toContain('message') + expect(columnNames).toContain('modComment') + expect(columnNames).toContain('internalNote') + expect(columnNames).toContain('acknowledgedAt') + expect(columnNames).toContain('createdAt') + }) + + it('should have id as primary key', () => { + const columns = getTableColumns(modWarnings) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(modWarnings) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.targetDid.notNull).toBe(true) + expect(columns.moderatorDid.notNull).toBe(true) + expect(columns.warningType.notNull).toBe(true) + expect(columns.message.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) + + it('should allow nullable optional fields', () => { + const columns = getTableColumns(modWarnings) + expect(columns.modComment.notNull).toBe(false) + expect(columns.internalNote.notNull).toBe(false) + expect(columns.acknowledgedAt.notNull).toBe(false) + }) +}) diff --git a/tests/unit/db/schema/moderation-actions.test.ts b/tests/unit/db/schema/moderation-actions.test.ts index 60dd17a..b5a8a00 100644 --- a/tests/unit/db/schema/moderation-actions.test.ts +++ b/tests/unit/db/schema/moderation-actions.test.ts @@ -40,4 +40,13 @@ describe('moderationActions schema', () => { expect(columns.targetUri.notNull).toBe(false) expect(columns.targetDid.notNull).toBe(false) }) + + it('should include annotation action types in enum', () => { + const columns = getTableColumns(moderationActions) + const actionCol = columns.action as unknown as { enumValues: string[] } + expect(actionCol.enumValues).toContain('note_created') + expect(actionCol.enumValues).toContain('warning_issued') + expect(actionCol.enumValues).toContain('notice_added') + expect(actionCol.enumValues).toContain('notice_removed') + }) }) diff --git a/tests/unit/db/schema/topic-notices.test.ts b/tests/unit/db/schema/topic-notices.test.ts new file mode 100644 index 0000000..5401945 --- /dev/null +++ b/tests/unit/db/schema/topic-notices.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { topicNotices } from '../../../../src/db/schema/topic-notices.js' +import { getTableName, getTableColumns } from 'drizzle-orm' + +describe('topicNotices schema', () => { + it('should have the correct table name', () => { + expect(getTableName(topicNotices)).toBe('topic_notices') + }) + + it('should have all required columns', () => { + const columns = getTableColumns(topicNotices) + const columnNames = Object.keys(columns) + expect(columnNames).toContain('id') + expect(columnNames).toContain('communityDid') + expect(columnNames).toContain('topicUri') + expect(columnNames).toContain('authorDid') + expect(columnNames).toContain('noticeType') + expect(columnNames).toContain('headline') + expect(columnNames).toContain('body') + expect(columnNames).toContain('createdAt') + expect(columnNames).toContain('dismissedAt') + }) + + it('should have id as primary key', () => { + const columns = getTableColumns(topicNotices) + expect(columns.id.primary).toBe(true) + }) + + it('should mark required columns as not null', () => { + const columns = getTableColumns(topicNotices) + expect(columns.communityDid.notNull).toBe(true) + expect(columns.topicUri.notNull).toBe(true) + expect(columns.authorDid.notNull).toBe(true) + expect(columns.noticeType.notNull).toBe(true) + expect(columns.headline.notNull).toBe(true) + expect(columns.createdAt.notNull).toBe(true) + }) + + it('should allow nullable body and dismissedAt', () => { + const columns = getTableColumns(topicNotices) + expect(columns.body.notNull).toBe(false) + expect(columns.dismissedAt.notNull).toBe(false) + }) +}) diff --git a/tests/unit/routes/mod-annotations.test.ts b/tests/unit/routes/mod-annotations.test.ts new file mode 100644 index 0000000..e89b939 --- /dev/null +++ b/tests/unit/routes/mod-annotations.test.ts @@ -0,0 +1,1042 @@ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import type { Env } from '../../../src/config/env.js' +import type { AuthMiddleware, RequestUser } from '../../../src/auth/middleware.js' +import type { SessionService } from '../../../src/auth/session.js' +import type { SetupService } from '../../../src/setup/service.js' +import { type DbChain, createChainableProxy, createMockDb } from '../../helpers/mock-db.js' + +// --------------------------------------------------------------------------- +// Mock requireModerator module (must be before importing routes) +// --------------------------------------------------------------------------- + +const mockRequireModerator = + vi.fn<(request: FastifyRequest, reply: FastifyReply) => Promise>() + +vi.mock('../../../src/auth/require-moderator.js', () => ({ + createRequireModerator: () => mockRequireModerator, +})) + +// Import routes AFTER mocking +import { modAnnotationRoutes } from '../../../src/routes/mod-annotations.js' + +// --------------------------------------------------------------------------- +// Mock env +// --------------------------------------------------------------------------- + +const mockEnv = { + COMMUNITY_DID: 'did:plc:community123', + RATE_LIMIT_WRITE: 10, + RATE_LIMIT_READ_ANON: 100, + RATE_LIMIT_READ_AUTH: 300, +} as Env + +// --------------------------------------------------------------------------- +// Test constants +// --------------------------------------------------------------------------- + +const MOD_DID = 'did:plc:moderator1' +const MOD_HANDLE = 'mod.bsky.team' +const MOD_SID = 'b'.repeat(64) +const TARGET_DID = 'did:plc:targetuser1' +const COMMUNITY_DID = 'did:plc:community123' +const TEST_NOW = '2026-02-13T12:00:00.000Z' +const TEST_TOPIC_URI = `at://${TARGET_DID}/forum.barazo.topic.post/topic123` + +// --------------------------------------------------------------------------- +// Mock user builders +// --------------------------------------------------------------------------- + +function modUser(overrides?: Partial): RequestUser { + return { + did: MOD_DID, + handle: MOD_HANDLE, + sid: MOD_SID, + ...overrides, + } +} + +function targetUser(): RequestUser { + return { + did: TARGET_DID, + handle: 'target.bsky.team', + sid: 'c'.repeat(64), + } +} + +// --------------------------------------------------------------------------- +// Chainable mock DB +// --------------------------------------------------------------------------- + +const mockDb = createMockDb() + +let insertChain: DbChain +let selectChain: DbChain +let updateChain: DbChain +let deleteChain: DbChain + +function resetAllDbMocks(): void { + insertChain = createChainableProxy() + selectChain = createChainableProxy([]) + updateChain = createChainableProxy([]) + deleteChain = createChainableProxy() + mockDb.insert.mockReturnValue(insertChain) + mockDb.select.mockReturnValue(selectChain) + mockDb.update.mockReturnValue(updateChain) + mockDb.delete.mockReturnValue(deleteChain) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Intentionally async mock for Drizzle transaction + mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockDb) => Promise) => { + return await fn(mockDb) + }) +} + +// --------------------------------------------------------------------------- +// Auth middleware mocks +// --------------------------------------------------------------------------- + +function createMockAuthMiddleware(user?: RequestUser): AuthMiddleware { + return { + requireAuth: async (request, reply) => { + if (!user) { + await reply.status(401).send({ error: 'Authentication required' }) + return + } + request.user = user + }, + optionalAuth: (request, _reply) => { + if (user) { + request.user = user + } + return Promise.resolve() + }, + } +} + +// --------------------------------------------------------------------------- +// Sample data builders +// --------------------------------------------------------------------------- + +function sampleModNote(overrides?: Record) { + return { + id: 1, + communityDid: COMMUNITY_DID, + authorDid: MOD_DID, + subjectDid: TARGET_DID, + subjectUri: null, + content: 'Repeated spam behavior', + noteType: 'note', + createdAt: new Date(TEST_NOW), + ...overrides, + } +} + +function sampleTopicNotice(overrides?: Record) { + return { + id: 1, + communityDid: COMMUNITY_DID, + topicUri: TEST_TOPIC_URI, + authorDid: MOD_DID, + noticeType: 'closed', + headline: 'Topic closed', + body: 'This topic has been closed for discussion.', + createdAt: new Date(TEST_NOW), + dismissedAt: null, + ...overrides, + } +} + +function sampleWarning(overrides?: Record) { + return { + id: 1, + communityDid: COMMUNITY_DID, + targetDid: TARGET_DID, + moderatorDid: MOD_DID, + warningType: 'rule_violation', + message: 'Please follow the community guidelines.', + modComment: null, + internalNote: null, + acknowledgedAt: null, + createdAt: new Date(TEST_NOW), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Helper: build app with mocked deps +// --------------------------------------------------------------------------- + +async function buildTestApp(user?: RequestUser): Promise { + const app = Fastify({ logger: false }) + + const authMiddleware = createMockAuthMiddleware(user) + + app.decorate('db', mockDb as never) + app.decorate('env', mockEnv) + app.decorate('authMiddleware', authMiddleware) + app.decorate('firehose', {} as never) + app.decorate('oauthClient', {} as never) + app.decorate('sessionService', {} as SessionService) + app.decorate('setupService', {} as SetupService) + app.decorate('cache', {} as never) + app.decorateRequest('user', undefined as RequestUser | undefined) + app.decorateRequest('communityDid', undefined as string | undefined) + app.addHook('onRequest', (request, _reply, done) => { + request.communityDid = 'did:plc:test' + done() + }) + + await app.register(modAnnotationRoutes()) + await app.ready() + + return app +} + +// =========================================================================== +// Test suite +// =========================================================================== + +describe('mod annotation routes', () => { + // ========================================================================= + // POST /api/mod-notes + // ========================================================================= + + describe('POST /api/mod-notes', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('creates a mod note for a user (subjectDid) and returns it', async () => { + const note = sampleModNote() + insertChain.returning.mockResolvedValueOnce([note]) + + const response = await app.inject({ + method: 'POST', + url: '/api/mod-notes', + headers: { authorization: 'Bearer test-token' }, + payload: { + subjectDid: TARGET_DID, + content: 'Repeated spam behavior', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ note: { id: number } }>() + expect(body.note.id).toBe(1) + expect(body.note).toHaveProperty('content', 'Repeated spam behavior') + + // Audit trail + expect(mockDb.insert).toHaveBeenCalledTimes(2) // mod_note + moderation_action + }) + + it('creates a mod note for a post (subjectUri) and returns it', async () => { + const note = sampleModNote({ subjectDid: null, subjectUri: TEST_TOPIC_URI }) + insertChain.returning.mockResolvedValueOnce([note]) + + const response = await app.inject({ + method: 'POST', + url: '/api/mod-notes', + headers: { authorization: 'Bearer test-token' }, + payload: { + subjectUri: TEST_TOPIC_URI, + content: 'Off-topic content', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ note: { subjectUri: string } }>() + expect(body.note.subjectUri).toBe(TEST_TOPIC_URI) + }) + + it('returns 400 when both subjectDid and subjectUri are provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/mod-notes', + headers: { authorization: 'Bearer test-token' }, + payload: { + subjectDid: TARGET_DID, + subjectUri: TEST_TOPIC_URI, + content: 'Bad request', + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 400 when neither subjectDid nor subjectUri is provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/mod-notes', + headers: { authorization: 'Bearer test-token' }, + payload: { + content: 'No subject', + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'POST', + url: '/api/mod-notes', + payload: { + subjectDid: TARGET_DID, + content: 'Should fail', + }, + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // GET /api/mod-notes + // ========================================================================= + + describe('GET /api/mod-notes', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('lists mod notes with default pagination', async () => { + const notes = [sampleModNote(), sampleModNote({ id: 2 })] + selectChain.limit.mockResolvedValueOnce(notes) + + const response = await app.inject({ + method: 'GET', + url: '/api/mod-notes', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ notes: unknown[]; cursor: string | null }>() + expect(body.notes).toHaveLength(2) + expect(body.cursor).toBeNull() + }) + + it('filters mod notes by subjectDid', async () => { + selectChain.limit.mockResolvedValueOnce([sampleModNote()]) + + const response = await app.inject({ + method: 'GET', + url: `/api/mod-notes?subjectDid=${TARGET_DID}`, + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ notes: unknown[] }>() + expect(body.notes).toHaveLength(1) + }) + + it('returns cursor when more results exist', async () => { + // limit defaults to 25, so 26 results means hasMore=true + const manyNotes = Array.from({ length: 26 }, (_, i) => + sampleModNote({ id: i + 1 }) + ) + selectChain.limit.mockResolvedValueOnce(manyNotes) + + const response = await app.inject({ + method: 'GET', + url: '/api/mod-notes', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ notes: unknown[]; cursor: string | null }>() + expect(body.notes).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'GET', + url: '/api/mod-notes', + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // DELETE /api/mod-notes/:id + // ========================================================================= + + describe('DELETE /api/mod-notes/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('deletes a mod note and returns success', async () => { + // First select finds the note, then delete runs + selectChain.where.mockResolvedValueOnce([sampleModNote()]) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/mod-notes/1', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ success: boolean }>() + expect(body.success).toBe(true) + }) + + it('returns 404 when note does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/mod-notes/999', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 for invalid id', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/mod-notes/abc', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/mod-notes/1', + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // POST /api/topic-notices + // ========================================================================= + + describe('POST /api/topic-notices', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('creates a topic notice and returns it', async () => { + const notice = sampleTopicNotice() + insertChain.returning.mockResolvedValueOnce([notice]) + + const response = await app.inject({ + method: 'POST', + url: '/api/topic-notices', + headers: { authorization: 'Bearer test-token' }, + payload: { + topicUri: TEST_TOPIC_URI, + noticeType: 'closed', + headline: 'Topic closed', + body: 'This topic has been closed for discussion.', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ notice: { id: number } }>() + expect(body.notice.id).toBe(1) + expect(body.notice).toHaveProperty('noticeType', 'closed') + }) + + it('returns 400 when required fields are missing', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/topic-notices', + headers: { authorization: 'Bearer test-token' }, + payload: { + topicUri: TEST_TOPIC_URI, + // missing noticeType and headline + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'POST', + url: '/api/topic-notices', + payload: { + topicUri: TEST_TOPIC_URI, + noticeType: 'closed', + headline: 'Topic closed', + }, + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // GET /api/topic-notices + // ========================================================================= + + describe('GET /api/topic-notices', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('returns active notices for a specific topicUri (public, no auth needed)', async () => { + // Even without auth, filtering by topicUri should work + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const notices = [sampleTopicNotice()] + selectChain.limit.mockResolvedValueOnce(notices) + + const response = await app.inject({ + method: 'GET', + url: `/api/topic-notices?topicUri=${encodeURIComponent(TEST_TOPIC_URI)}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ notices: unknown[] }>() + expect(body.notices).toHaveLength(1) + }) + + it('returns all notices (including dismissed) for moderators without topicUri filter', async () => { + const notices = [ + sampleTopicNotice(), + sampleTopicNotice({ id: 2, dismissedAt: new Date(TEST_NOW) }), + ] + selectChain.limit.mockResolvedValueOnce(notices) + + const response = await app.inject({ + method: 'GET', + url: '/api/topic-notices', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ notices: unknown[] }>() + expect(body.notices).toHaveLength(2) + }) + + it('returns 401 when no topicUri and not authenticated as moderator', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'GET', + url: '/api/topic-notices', + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // DELETE /api/topic-notices/:id (soft delete / dismiss) + // ========================================================================= + + describe('DELETE /api/topic-notices/:id', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('dismisses a topic notice (soft delete) and returns it', async () => { + selectChain.where.mockResolvedValueOnce([sampleTopicNotice()]) + const dismissed = sampleTopicNotice({ dismissedAt: new Date(TEST_NOW) }) + updateChain.returning.mockResolvedValueOnce([dismissed]) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/topic-notices/1', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ notice: { dismissedAt: string | null } }>() + expect(body.notice.dismissedAt).toBeTruthy() + }) + + it('returns 404 when notice does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/topic-notices/999', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 for invalid id', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/topic-notices/abc', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'DELETE', + url: '/api/topic-notices/1', + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // POST /api/warnings + // ========================================================================= + + describe('POST /api/warnings', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('creates a warning and returns it', async () => { + const warning = sampleWarning() + insertChain.returning.mockResolvedValueOnce([warning]) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings', + headers: { authorization: 'Bearer test-token' }, + payload: { + targetDid: TARGET_DID, + warningType: 'rule_violation', + message: 'Please follow the community guidelines.', + }, + }) + + expect(response.statusCode).toBe(201) + const body = response.json<{ warning: { id: number } }>() + expect(body.warning.id).toBe(1) + expect(body.warning).toHaveProperty('warningType', 'rule_violation') + }) + + it('creates a warning with internalNote and also creates a mod note', async () => { + const warning = sampleWarning({ internalNote: 'This user has history' }) + insertChain.returning.mockResolvedValueOnce([warning]) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings', + headers: { authorization: 'Bearer test-token' }, + payload: { + targetDid: TARGET_DID, + warningType: 'rule_violation', + message: 'Please follow the community guidelines.', + internalNote: 'This user has history', + }, + }) + + expect(response.statusCode).toBe(201) + // warning insert + mod_note insert + moderation_action insert = 3 inserts + expect(mockDb.insert).toHaveBeenCalledTimes(3) + }) + + it('returns 400 when required fields are missing', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/warnings', + headers: { authorization: 'Bearer test-token' }, + payload: { + targetDid: TARGET_DID, + // missing warningType and message + }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings', + payload: { + targetDid: TARGET_DID, + warningType: 'rule_violation', + message: 'Should fail', + }, + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // GET /api/warnings + // ========================================================================= + + describe('GET /api/warnings', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(modUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + + mockRequireModerator.mockImplementation((request) => { + request.user = modUser() + return Promise.resolve() + }) + }) + + it('lists warnings with default pagination', async () => { + const warnings = [sampleWarning(), sampleWarning({ id: 2 })] + selectChain.limit.mockResolvedValueOnce(warnings) + + const response = await app.inject({ + method: 'GET', + url: '/api/warnings', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ warnings: unknown[]; cursor: string | null }>() + expect(body.warnings).toHaveLength(2) + expect(body.cursor).toBeNull() + }) + + it('filters warnings by targetDid', async () => { + selectChain.limit.mockResolvedValueOnce([sampleWarning()]) + + const response = await app.inject({ + method: 'GET', + url: `/api/warnings?targetDid=${TARGET_DID}`, + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ warnings: unknown[] }>() + expect(body.warnings).toHaveLength(1) + }) + + it('returns 401 when not authenticated', async () => { + mockRequireModerator.mockImplementation(async (_request, reply) => { + await reply.status(401).send({ error: 'Authentication required' }) + }) + + const response = await app.inject({ + method: 'GET', + url: '/api/warnings', + }) + + expect(response.statusCode).toBe(401) + }) + }) + + // ========================================================================= + // POST /api/warnings/:id/acknowledge + // ========================================================================= + + describe('POST /api/warnings/:id/acknowledge', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(targetUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('acknowledges a warning for the target user', async () => { + selectChain.where.mockResolvedValueOnce([sampleWarning()]) + const acknowledged = sampleWarning({ acknowledgedAt: new Date(TEST_NOW) }) + updateChain.returning.mockResolvedValueOnce([acknowledged]) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings/1/acknowledge', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ warning: { acknowledgedAt: string | null } }>() + expect(body.warning.acknowledgedAt).toBeTruthy() + }) + + it('returns 403 when user is not the warning target', async () => { + // The warning targets TARGET_DID but user is someone else + const warningForOther = sampleWarning({ targetDid: 'did:plc:someoneelse' }) + selectChain.where.mockResolvedValueOnce([warningForOther]) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings/1/acknowledge', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(403) + }) + + it('returns 404 when warning does not exist', async () => { + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings/999/acknowledge', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 400 when warning is already acknowledged', async () => { + selectChain.where.mockResolvedValueOnce([ + sampleWarning({ acknowledgedAt: new Date(TEST_NOW) }), + ]) + + const response = await app.inject({ + method: 'POST', + url: '/api/warnings/1/acknowledge', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 400 for invalid id', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/warnings/abc/acknowledge', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(400) + }) + + it('returns 401 when not authenticated', async () => { + const unauthApp = await buildTestApp() + const response = await unauthApp.inject({ + method: 'POST', + url: '/api/warnings/1/acknowledge', + }) + + expect(response.statusCode).toBe(401) + await unauthApp.close() + }) + }) + + // ========================================================================= + // GET /api/my-warnings + // ========================================================================= + + describe('GET /api/my-warnings', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(targetUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('lists own warnings in the community', async () => { + const warnings = [sampleWarning()] + selectChain.limit.mockResolvedValueOnce(warnings) + + const response = await app.inject({ + method: 'GET', + url: '/api/my-warnings', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ warnings: unknown[]; cursor: string | null }>() + expect(body.warnings).toHaveLength(1) + expect(body.cursor).toBeNull() + }) + + it('returns cursor when more results exist', async () => { + const manyWarnings = Array.from({ length: 26 }, (_, i) => + sampleWarning({ id: i + 1 }) + ) + selectChain.limit.mockResolvedValueOnce(manyWarnings) + + const response = await app.inject({ + method: 'GET', + url: '/api/my-warnings', + headers: { authorization: 'Bearer test-token' }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ warnings: unknown[]; cursor: string | null }>() + expect(body.warnings).toHaveLength(25) + expect(body.cursor).toBeTruthy() + }) + + it('returns 401 when not authenticated', async () => { + const unauthApp = await buildTestApp() + const response = await unauthApp.inject({ + method: 'GET', + url: '/api/my-warnings', + }) + + expect(response.statusCode).toBe(401) + await unauthApp.close() + }) + }) +}) diff --git a/tests/unit/validation/mod-annotations.test.ts b/tests/unit/validation/mod-annotations.test.ts new file mode 100644 index 0000000..4050557 --- /dev/null +++ b/tests/unit/validation/mod-annotations.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest' +import { + createModNoteSchema, + modNoteQuerySchema, + createTopicNoticeSchema, + // dismissTopicNoticeSchema and topicNoticeQuerySchema tested via route-level integration + createWarningSchema, + warningQuerySchema, + acknowledgeWarningSchema, +} from '../../../src/validation/mod-annotations.js' + +describe('mod annotation validation schemas', () => { + describe('createModNoteSchema', () => { + it('should accept valid user note', () => { + const result = createModNoteSchema.safeParse({ + subjectDid: 'did:plc:abc123', + content: 'User was warned verbally in chat.', + }) + expect(result.success).toBe(true) + }) + + it('should accept valid post note', () => { + const result = createModNoteSchema.safeParse({ + subjectUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + content: 'Borderline post, left up after review.', + }) + expect(result.success).toBe(true) + }) + + it('should reject note with both subjectDid and subjectUri', () => { + const result = createModNoteSchema.safeParse({ + subjectDid: 'did:plc:abc123', + subjectUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + content: 'Invalid.', + }) + expect(result.success).toBe(false) + }) + + it('should reject note with neither subjectDid nor subjectUri', () => { + const result = createModNoteSchema.safeParse({ + content: 'No subject.', + }) + expect(result.success).toBe(false) + }) + + it('should reject empty content', () => { + const result = createModNoteSchema.safeParse({ + subjectDid: 'did:plc:abc123', + content: '', + }) + expect(result.success).toBe(false) + }) + + it('should reject content over 5000 chars', () => { + const result = createModNoteSchema.safeParse({ + subjectDid: 'did:plc:abc123', + content: 'a'.repeat(5001), + }) + expect(result.success).toBe(false) + }) + }) + + describe('modNoteQuerySchema', () => { + it('should accept valid query with subjectDid', () => { + const result = modNoteQuerySchema.safeParse({ + subjectDid: 'did:plc:abc123', + }) + expect(result.success).toBe(true) + }) + + it('should accept valid query with subjectUri', () => { + const result = modNoteQuerySchema.safeParse({ + subjectUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + }) + expect(result.success).toBe(true) + }) + + it('should accept query with cursor and limit', () => { + const result = modNoteQuerySchema.safeParse({ + subjectDid: 'did:plc:abc123', + cursor: '10', + limit: 50, + }) + expect(result.success).toBe(true) + }) + + it('should default limit to 25', () => { + const result = modNoteQuerySchema.safeParse({ + subjectDid: 'did:plc:abc123', + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limit).toBe(25) + } + }) + }) + + describe('createTopicNoticeSchema', () => { + it('should accept valid notice', () => { + const result = createTopicNoticeSchema.safeParse({ + topicUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + noticeType: 'closed', + headline: 'This topic has been closed.', + }) + expect(result.success).toBe(true) + }) + + it('should accept notice with optional body', () => { + const result = createTopicNoticeSchema.safeParse({ + topicUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + noticeType: 'custom', + headline: 'Please read before posting.', + body: 'This topic has specific rules.', + }) + expect(result.success).toBe(true) + }) + + it('should reject headline over 200 chars', () => { + const result = createTopicNoticeSchema.safeParse({ + topicUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + noticeType: 'closed', + headline: 'a'.repeat(201), + }) + expect(result.success).toBe(false) + }) + + it('should reject invalid notice type', () => { + const result = createTopicNoticeSchema.safeParse({ + topicUri: 'at://did:plc:abc/forum.barazo.topic.post/123', + noticeType: 'invalid', + headline: 'Test.', + }) + expect(result.success).toBe(false) + }) + }) + + describe('createWarningSchema', () => { + it('should accept valid warning', () => { + const result = createWarningSchema.safeParse({ + targetDid: 'did:plc:user123', + warningType: 'rule_violation', + message: 'Your post violated community rule #3.', + }) + expect(result.success).toBe(true) + }) + + it('should accept warning with optional fields', () => { + const result = createWarningSchema.safeParse({ + targetDid: 'did:plc:user123', + warningType: 'harassment', + message: 'Please review our community guidelines.', + modComment: 'This is a first offense.', + internalNote: 'User has been argumentative in multiple threads.', + }) + expect(result.success).toBe(true) + }) + + it('should reject modComment over 300 chars', () => { + const result = createWarningSchema.safeParse({ + targetDid: 'did:plc:user123', + warningType: 'other', + message: 'Warning.', + modComment: 'a'.repeat(301), + }) + expect(result.success).toBe(false) + }) + + it('should reject empty message', () => { + const result = createWarningSchema.safeParse({ + targetDid: 'did:plc:user123', + warningType: 'other', + message: '', + }) + expect(result.success).toBe(false) + }) + }) + + describe('acknowledgeWarningSchema', () => { + it('should accept valid warning id param', () => { + const result = acknowledgeWarningSchema.safeParse({ id: '5' }) + expect(result.success).toBe(true) + }) + }) + + describe('warningQuerySchema', () => { + it('should accept query with targetDid', () => { + const result = warningQuerySchema.safeParse({ + targetDid: 'did:plc:user123', + }) + expect(result.success).toBe(true) + }) + + it('should default limit to 25', () => { + const result = warningQuerySchema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limit).toBe(25) + } + }) + }) +})