From ef6631216a737eac4bdcffd4af631b1bdb5c1b59 Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:53:52 -0400 Subject: [PATCH 1/6] Add Shell Society nomination flow --- drizzle/0004_puzzling_omega_flight.sql | 25 + drizzle/0005_lethal_senator_kelly.sql | 1 + drizzle/meta/0004_snapshot.json | 1142 +++++++++++++++++++++++ drizzle/meta/0005_snapshot.json | 1150 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 14 + src/commands/nominate.ts | 129 +++ src/components/nominationButtons.ts | 202 +++++ src/config/nominations.ts | 24 + src/data/nominations.ts | 117 +++ src/db/schema.ts | 46 + src/index.ts | 4 + tests/nominations.test.ts | 97 ++ 12 files changed, 2951 insertions(+) create mode 100644 drizzle/0004_puzzling_omega_flight.sql create mode 100644 drizzle/0005_lethal_senator_kelly.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/commands/nominate.ts create mode 100644 src/components/nominationButtons.ts create mode 100644 src/config/nominations.ts create mode 100644 src/data/nominations.ts create mode 100644 tests/nominations.test.ts diff --git a/drizzle/0004_puzzling_omega_flight.sql b/drizzle/0004_puzzling_omega_flight.sql new file mode 100644 index 0000000..e765dc5 --- /dev/null +++ b/drizzle/0004_puzzling_omega_flight.sql @@ -0,0 +1,25 @@ +CREATE TABLE `nomination_approvals` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `nomination_id` integer NOT NULL, + `approver_id` text NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_nomination_approvals_nomination_approver` ON `nomination_approvals` (`nomination_id`,`approver_id`);--> statement-breakpoint +CREATE INDEX `idx_nomination_approvals_nomination_id` ON `nomination_approvals` (`nomination_id`);--> statement-breakpoint +CREATE TABLE `nominations` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `guild_id` text NOT NULL, + `channel_id` text NOT NULL, + `nominee_id` text NOT NULL, + `nominator_id` text NOT NULL, + `target_role_id` text NOT NULL, + `required_approvals` integer NOT NULL, + `status` text DEFAULT 'submitted' NOT NULL, + `completed_at` text, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_nominations_guild_nominee_status` ON `nominations` (`guild_id`,`nominee_id`,`status`);--> statement-breakpoint +CREATE INDEX `idx_nominations_status` ON `nominations` (`status`); \ No newline at end of file diff --git a/drizzle/0005_lethal_senator_kelly.sql b/drizzle/0005_lethal_senator_kelly.sql new file mode 100644 index 0000000..d516768 --- /dev/null +++ b/drizzle/0005_lethal_senator_kelly.sql @@ -0,0 +1 @@ +ALTER TABLE `nominations` ADD `reason` text DEFAULT 'No reason provided.' NOT NULL; diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..a6f154f --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1142 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "93678d3c-195c-4837-a453-e73296d955ed", + "prevId": "02ac93cc-e55a-447f-b697-586ca86e97bb", + "tables": { + "claim_requests": { + "name": "claim_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merged_pr_count": { + "name": "merged_pr_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_claim_requests_guild_user": { + "name": "idx_claim_requests_guild_user", + "columns": [ + "guild_id", + "user_id" + ], + "isUnique": true + }, + "idx_claim_requests_user_id": { + "name": "idx_claim_requests_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_claim_requests_status": { + "name": "idx_claim_requests_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_cases": { + "name": "clawhub_content_rights_cases", + "columns": { + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_submission_id": { + "name": "form_submission_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "requester_name": { + "name": "requester_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clawhub_urls": { + "name": "clawhub_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_cases_form_submission_id_unique": { + "name": "clawhub_content_rights_cases_form_submission_id_unique", + "columns": [ + "form_submission_id" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_cases_status": { + "name": "idx_clawhub_content_rights_cases_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_cases_email": { + "name": "idx_clawhub_content_rights_cases_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_events": { + "name": "clawhub_content_rights_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_clawhub_content_rights_events_case_id": { + "name": "idx_clawhub_content_rights_events_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_events_event_type": { + "name": "idx_clawhub_content_rights_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_files": { + "name": "clawhub_content_rights_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_files_object_key_unique": { + "name": "clawhub_content_rights_files_object_key_unique", + "columns": [ + "object_key" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_files_case_id": { + "name": "idx_clawhub_content_rights_files_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_submissions": { + "name": "form_submissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_id": { + "name": "applicant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_username": { + "name": "applicant_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_channel_id": { + "name": "review_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action_result": { + "name": "action_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_form_submissions_form_id": { + "name": "idx_form_submissions_form_id", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "idx_form_submissions_status": { + "name": "idx_form_submissions_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_form_submissions_applicant_id": { + "name": "idx_form_submissions_applicant_id", + "columns": [ + "applicant_id" + ], + "isUnique": false + }, + "idx_form_submissions_review_message_id": { + "name": "idx_form_submissions_review_message_id", + "columns": [ + "review_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "helper_events": { + "name": "helper_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'helper_command'" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_time": { + "name": "event_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoked_by_id": { + "name": "invoked_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_username": { + "name": "invoked_by_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_global_name": { + "name": "invoked_by_global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_helper_events_event_time": { + "name": "idx_helper_events_event_time", + "columns": [ + "event_time" + ], + "isUnique": false + }, + "idx_helper_events_command": { + "name": "idx_helper_events_command", + "columns": [ + "command" + ], + "isUnique": false + }, + "idx_helper_events_thread_id": { + "name": "idx_helper_events_thread_id", + "columns": [ + "thread_id" + ], + "isUnique": false + }, + "idx_helper_events_invoked_by_id": { + "name": "idx_helper_events_invoked_by_id", + "columns": [ + "invoked_by_id" + ], + "isUnique": false + }, + "idx_helper_events_event_type": { + "name": "idx_helper_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + }, + "idx_helper_events_thread_time": { + "name": "idx_helper_events_thread_time", + "columns": [ + "thread_id", + "event_time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyValue": { + "name": "keyValue", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nomination_approvals": { + "name": "nomination_approvals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "nomination_id": { + "name": "nomination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approver_id": { + "name": "approver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nomination_approvals_nomination_approver": { + "name": "idx_nomination_approvals_nomination_approver", + "columns": [ + "nomination_id", + "approver_id" + ], + "isUnique": true + }, + "idx_nomination_approvals_nomination_id": { + "name": "idx_nomination_approvals_nomination_id", + "columns": [ + "nomination_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nominations": { + "name": "nominations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominee_id": { + "name": "nominee_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominator_id": { + "name": "nominator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_role_id": { + "name": "target_role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_approvals": { + "name": "required_approvals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nominations_guild_nominee_status": { + "name": "idx_nominations_guild_nominee_status", + "columns": [ + "guild_id", + "nominee_id", + "status" + ], + "isUnique": false + }, + "idx_nominations_status": { + "name": "idx_nominations_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_moderation_contexts": { + "name": "reddit_moderation_contexts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'moderated'" + }, + "unaction": { + "name": "unaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reviewed'" + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderator": { + "name": "moderator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_at": { + "name": "banned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_reddit_moderation_contexts_subreddit_username": { + "name": "idx_reddit_moderation_contexts_subreddit_username", + "columns": [ + "subreddit", + "username" + ], + "isUnique": true + }, + "idx_reddit_moderation_contexts_username": { + "name": "idx_reddit_moderation_contexts_username", + "columns": [ + "username" + ], + "isUnique": false + }, + "idx_reddit_moderation_contexts_action": { + "name": "idx_reddit_moderation_contexts_action", + "columns": [ + "action" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracked_threads": { + "name": "tracked_threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "solved": { + "name": "solved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "warning_level": { + "name": "warning_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_message_count": { + "name": "last_message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tracked_threads_thread_id_unique": { + "name": "tracked_threads_thread_id_unique", + "columns": [ + "thread_id" + ], + "isUnique": true + }, + "idx_tracked_threads_solved": { + "name": "idx_tracked_threads_solved", + "columns": [ + "solved" + ], + "isUnique": false + }, + "idx_tracked_threads_last_checked": { + "name": "idx_tracked_threads_last_checked", + "columns": [ + "last_checked" + ], + "isUnique": false + }, + "idx_tracked_threads_received_at": { + "name": "idx_tracked_threads_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + }, + "idx_tracked_threads_closed": { + "name": "idx_tracked_threads_closed", + "columns": [ + "closed" + ], + "isUnique": false + }, + "idx_tracked_threads_warning_level": { + "name": "idx_tracked_threads_warning_level", + "columns": [ + "warning_level" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..887df9c --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1150 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b40ffb14-0380-48b0-90ab-1a97e3fbb7c0", + "prevId": "93678d3c-195c-4837-a453-e73296d955ed", + "tables": { + "claim_requests": { + "name": "claim_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merged_pr_count": { + "name": "merged_pr_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_claim_requests_guild_user": { + "name": "idx_claim_requests_guild_user", + "columns": [ + "guild_id", + "user_id" + ], + "isUnique": true + }, + "idx_claim_requests_user_id": { + "name": "idx_claim_requests_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_claim_requests_status": { + "name": "idx_claim_requests_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_cases": { + "name": "clawhub_content_rights_cases", + "columns": { + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_submission_id": { + "name": "form_submission_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "requester_name": { + "name": "requester_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clawhub_urls": { + "name": "clawhub_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_cases_form_submission_id_unique": { + "name": "clawhub_content_rights_cases_form_submission_id_unique", + "columns": [ + "form_submission_id" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_cases_status": { + "name": "idx_clawhub_content_rights_cases_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_cases_email": { + "name": "idx_clawhub_content_rights_cases_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_events": { + "name": "clawhub_content_rights_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_clawhub_content_rights_events_case_id": { + "name": "idx_clawhub_content_rights_events_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_events_event_type": { + "name": "idx_clawhub_content_rights_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_files": { + "name": "clawhub_content_rights_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_files_object_key_unique": { + "name": "clawhub_content_rights_files_object_key_unique", + "columns": [ + "object_key" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_files_case_id": { + "name": "idx_clawhub_content_rights_files_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_submissions": { + "name": "form_submissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_id": { + "name": "applicant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_username": { + "name": "applicant_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_channel_id": { + "name": "review_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action_result": { + "name": "action_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_form_submissions_form_id": { + "name": "idx_form_submissions_form_id", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "idx_form_submissions_status": { + "name": "idx_form_submissions_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_form_submissions_applicant_id": { + "name": "idx_form_submissions_applicant_id", + "columns": [ + "applicant_id" + ], + "isUnique": false + }, + "idx_form_submissions_review_message_id": { + "name": "idx_form_submissions_review_message_id", + "columns": [ + "review_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "helper_events": { + "name": "helper_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'helper_command'" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_time": { + "name": "event_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoked_by_id": { + "name": "invoked_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_username": { + "name": "invoked_by_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_global_name": { + "name": "invoked_by_global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_helper_events_event_time": { + "name": "idx_helper_events_event_time", + "columns": [ + "event_time" + ], + "isUnique": false + }, + "idx_helper_events_command": { + "name": "idx_helper_events_command", + "columns": [ + "command" + ], + "isUnique": false + }, + "idx_helper_events_thread_id": { + "name": "idx_helper_events_thread_id", + "columns": [ + "thread_id" + ], + "isUnique": false + }, + "idx_helper_events_invoked_by_id": { + "name": "idx_helper_events_invoked_by_id", + "columns": [ + "invoked_by_id" + ], + "isUnique": false + }, + "idx_helper_events_event_type": { + "name": "idx_helper_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + }, + "idx_helper_events_thread_time": { + "name": "idx_helper_events_thread_time", + "columns": [ + "thread_id", + "event_time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyValue": { + "name": "keyValue", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nomination_approvals": { + "name": "nomination_approvals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "nomination_id": { + "name": "nomination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approver_id": { + "name": "approver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nomination_approvals_nomination_approver": { + "name": "idx_nomination_approvals_nomination_approver", + "columns": [ + "nomination_id", + "approver_id" + ], + "isUnique": true + }, + "idx_nomination_approvals_nomination_id": { + "name": "idx_nomination_approvals_nomination_id", + "columns": [ + "nomination_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nominations": { + "name": "nominations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominee_id": { + "name": "nominee_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominator_id": { + "name": "nominator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No reason provided.'", + "autoincrement": false + }, + "target_role_id": { + "name": "target_role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_approvals": { + "name": "required_approvals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nominations_guild_nominee_status": { + "name": "idx_nominations_guild_nominee_status", + "columns": [ + "guild_id", + "nominee_id", + "status" + ], + "isUnique": false + }, + "idx_nominations_status": { + "name": "idx_nominations_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_moderation_contexts": { + "name": "reddit_moderation_contexts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'moderated'" + }, + "unaction": { + "name": "unaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reviewed'" + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderator": { + "name": "moderator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_at": { + "name": "banned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_reddit_moderation_contexts_subreddit_username": { + "name": "idx_reddit_moderation_contexts_subreddit_username", + "columns": [ + "subreddit", + "username" + ], + "isUnique": true + }, + "idx_reddit_moderation_contexts_username": { + "name": "idx_reddit_moderation_contexts_username", + "columns": [ + "username" + ], + "isUnique": false + }, + "idx_reddit_moderation_contexts_action": { + "name": "idx_reddit_moderation_contexts_action", + "columns": [ + "action" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracked_threads": { + "name": "tracked_threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "solved": { + "name": "solved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "warning_level": { + "name": "warning_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_message_count": { + "name": "last_message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tracked_threads_thread_id_unique": { + "name": "tracked_threads_thread_id_unique", + "columns": [ + "thread_id" + ], + "isUnique": true + }, + "idx_tracked_threads_solved": { + "name": "idx_tracked_threads_solved", + "columns": [ + "solved" + ], + "isUnique": false + }, + "idx_tracked_threads_last_checked": { + "name": "idx_tracked_threads_last_checked", + "columns": [ + "last_checked" + ], + "isUnique": false + }, + "idx_tracked_threads_received_at": { + "name": "idx_tracked_threads_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + }, + "idx_tracked_threads_closed": { + "name": "idx_tracked_threads_closed", + "columns": [ + "closed" + ], + "isUnique": false + }, + "idx_tracked_threads_warning_level": { + "name": "idx_tracked_threads_warning_level", + "columns": [ + "warning_level" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d311bd6..cee61ba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1781564580172, "tag": "0003_clawhub_content_rights", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1782182989552, + "tag": "0004_puzzling_omega_flight", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1782184143387, + "tag": "0005_lethal_senator_kelly", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/commands/nominate.ts b/src/commands/nominate.ts new file mode 100644 index 0000000..f30c8b0 --- /dev/null +++ b/src/commands/nominate.ts @@ -0,0 +1,129 @@ +import { + ApplicationCommandOptionType, + ApplicationIntegrationType, + type CommandInteraction, + InteractionContextType +} from "@buape/carbon" +import { buildNominationContainer } from "../components/nominationButtons.js" +import { nominationConfig } from "../config/nominations.js" +import { + createNomination, + getActiveNominationForNominee, + getNominationApproverIds +} from "../data/nominations.js" +import BaseCommand from "./base.js" + +export default class NominateCommand extends BaseCommand { + name = nominationConfig.commandName + description = "Nominate a user for Shell Society" + contexts = [InteractionContextType.Guild] + integrationTypes = [ApplicationIntegrationType.GuildInstall] + options = [ + { + type: ApplicationCommandOptionType.User as const, + name: "user", + description: "The user to nominate", + required: true + }, + { + type: ApplicationCommandOptionType.String as const, + name: "reason", + description: "Why this user should join Shell Society", + required: true + } + ] + + async run(interaction: CommandInteraction) { + const channelId = interaction.rawData.channel_id ?? interaction.channel?.id + if (!channelId || !nominationConfig.nominationChannelIds.includes(channelId)) { + await interaction.reply({ + content: nominationConfig.copy.wrongChannel, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (!interaction.guild || !interaction.user?.id) { + return + } + + const target = interaction.options.getUser("user", true) + const reason = interaction.options.getString("reason", true).trim() + if (reason.length === 0) { + await interaction.reply({ + content: nominationConfig.copy.reasonRequired, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (target.id === interaction.user.id) { + await interaction.reply({ + content: nominationConfig.copy.selfNomination, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (target.bot) { + await interaction.reply({ + content: nominationConfig.copy.botNomination, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const targetMember = await interaction.guild.fetchMember(target.id).catch(() => null) + if (!targetMember) { + await interaction.reply({ + content: "User not found in the server.", + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (targetMember.roles.some((role) => role.id === nominationConfig.targetRoleId)) { + await interaction.reply({ + content: nominationConfig.copy.alreadyHasRole, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const existingNomination = await getActiveNominationForNominee( + nominationConfig.guildId, + target.id, + nominationConfig.targetRoleId + ) + if (existingNomination) { + await interaction.reply({ + content: nominationConfig.copy.alreadyPending, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const nomination = await createNomination({ + guildId: nominationConfig.guildId, + channelId, + nomineeId: target.id, + nominatorId: interaction.user.id, + reason, + targetRoleId: nominationConfig.targetRoleId, + requiredApprovals: nominationConfig.requiredApprovals + }) + const approverIds = await getNominationApproverIds(nomination.id) + + await interaction.reply({ + components: [buildNominationContainer(nomination, approverIds)], + allowedMentions: { parse: [] } + }) + } +} diff --git a/src/components/nominationButtons.ts b/src/components/nominationButtons.ts new file mode 100644 index 0000000..3c6316b --- /dev/null +++ b/src/components/nominationButtons.ts @@ -0,0 +1,202 @@ +import { + Button, + type ButtonInteraction, + ButtonStyle, + type ComponentData, + Container, + Row, + Separator, + TextDisplay +} from "@buape/carbon" +import { nominationConfig } from "../config/nominations.js" +import { + getNomination, + getNominationApproverIds, + markNominationApproved, + recordNominationApproval +} from "../data/nominations.js" +import type { Nomination } from "../db/schema.js" +import { getRuntimeEnv } from "../runtime/env.js" + +const discordApiBase = "https://discord.com/api/v10" + +const parseNominationId = (id: unknown) => { + if (typeof id === "number" && Number.isInteger(id)) { + return id + } + if (typeof id === "string" && /^\d+$/.test(id)) { + return Number(id) + } + return null +} + +const hasApproverRole = (interaction: ButtonInteraction) => + interaction.member?.roles.some((role) => + nominationConfig.approverRoleIds.includes(role.id) + ) ?? false + +const addTargetRole = async (nomination: Nomination) => { + const roleResponse = await fetch( + `${discordApiBase}/guilds/${nomination.guildId}/members/${nomination.nomineeId}/roles/${nomination.targetRoleId}`, + { + method: "PUT", + headers: { + Authorization: `Bot ${getRuntimeEnv().DISCORD_BOT_TOKEN}` + } + } + ) + + return roleResponse.ok || roleResponse.status === 204 +} + +export const buildNominationContainer = ( + nomination: Nomination, + approverIds: string[] +) => { + const approved = nomination.status === "approved" + const body = approved + ? `<@${nomination.nomineeId}> welcome to the Shell Society! +This is a private section of the server that is high signal, low noise, for the valued members of the server to gather together without the chaotic madness that is <#1456350065223270435>. + +Just remember, this is not a channel to share your PRs, etc; it’s only a social channel so please treat it as such and above all else, enjoy! 🐚🦞` + : `<@${nomination.nomineeId}> has been nominated by <@${nomination.nominatorId}> for ${nomination.reason}.` + + return new Container( + [ + new TextDisplay(`### ${nominationConfig.copy.title}`), + new TextDisplay(body), + new TextDisplay(`Approvals: ${Math.min(approverIds.length, nomination.requiredApprovals)}/${nomination.requiredApprovals}`), + new Separator({ divider: true, spacing: "small" }), + new Row([new NominationApproveButton(nomination.id, approved)]) + ], + { accentColor: approved ? "#3fb950" : "#f1c40f" } + ) +} + +export class NominationApproveButton extends Button { + customId = "nomination-approve" + label = nominationConfig.copy.buttonLabel + style = ButtonStyle.Success + ephemeral = true + defer = true + disabled = false + + constructor(id?: number, disabled = false) { + super() + if (typeof id === "number") { + this.customId = `nomination-approve:id=${id}` + } + this.disabled = disabled + } + + async run(interaction: ButtonInteraction, data: ComponentData) { + const id = parseNominationId(data.id) + if (!id) { + await interaction.reply({ + content: nominationConfig.copy.invalidNomination, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const nomination = await getNomination(id) + if (!nomination) { + await interaction.reply({ + content: nominationConfig.copy.invalidNomination, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (nomination.status === "approved") { + await interaction.reply({ + content: nominationConfig.copy.alreadyComplete, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (!hasApproverRole(interaction)) { + await interaction.reply({ + content: nominationConfig.copy.noPermission, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const approverId = interaction.user?.id ?? interaction.userId + if (!approverId) { + await interaction.reply({ + content: nominationConfig.copy.invalidNomination, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const recorded = await recordNominationApproval(nomination.id, approverId) + const approverIds = await getNominationApproverIds(nomination.id) + if (!recorded && approverIds.length < nomination.requiredApprovals) { + await interaction.reply({ + content: nominationConfig.copy.alreadyApproved, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (approverIds.length < nomination.requiredApprovals) { + await interaction.message?.edit({ + components: [buildNominationContainer(nomination, approverIds)], + allowedMentions: { parse: [] } + }).catch(() => null) + await interaction.reply({ + content: `${nominationConfig.copy.approvalRecorded} ${approverIds.length}/${nomination.requiredApprovals}.`, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + if (!(await addTargetRole(nomination))) { + await interaction.message?.edit({ + components: [buildNominationContainer(nomination, approverIds)], + allowedMentions: { parse: [] } + }).catch(() => null) + await interaction.reply({ + content: nominationConfig.copy.roleAddFailed, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + const approvedNomination = await markNominationApproved(nomination.id) + if (!approvedNomination) { + await interaction.reply({ + content: nominationConfig.copy.alreadyComplete, + ephemeral: true, + allowedMentions: { parse: [] } + }) + return + } + + await interaction.message?.edit({ + components: [buildNominationContainer(approvedNomination, approverIds)], + allowedMentions: { parse: [] } + }).catch(() => null) + await interaction.reply({ + content: nominationConfig.copy.approvalRecorded, + ephemeral: true, + allowedMentions: { parse: [] } + }) + } +} + +export const nominationComponents = [ + new NominationApproveButton() +] diff --git a/src/config/nominations.ts b/src/config/nominations.ts new file mode 100644 index 0000000..5d67e4b --- /dev/null +++ b/src/config/nominations.ts @@ -0,0 +1,24 @@ +export const nominationConfig = { + guildId: "1456350064065904867", + nominationChannelIds: ["1471742293055635536"], + approverRoleIds: ["1477360613125787678"], + targetRoleId: "1470356404706607227", + requiredApprovals: 2, + commandName: "nominate", + copy: { + title: "🐚🐚 Society nomination! 🐚🐚", + buttonLabel: "Approve", + wrongChannel: "This command can only be used in a secret channel... 🐚", + selfNomination: "You cannot nominate yourself.", + reasonRequired: "Reason required. The shell demands context.", + botNomination: "Bots cannot receive the shell. They know what they did.", + alreadyHasRole: "That user already has Shell Society.", + alreadyPending: "That user already has an open Shell Society nomination.", + noPermission: "Community Team only. Nice try though.", + alreadyApproved: "You already approved this one. The shell remembers.", + approvalRecorded: "Approval recorded.", + alreadyComplete: "This nomination is already complete.", + roleAddFailed: "Could not add Shell Society. Check bot permissions and role order.", + invalidNomination: "Could not load this nomination." + } +} diff --git a/src/data/nominations.ts b/src/data/nominations.ts new file mode 100644 index 0000000..826a8b5 --- /dev/null +++ b/src/data/nominations.ts @@ -0,0 +1,117 @@ +import { and, asc, eq, sql } from "drizzle-orm" +import { getDb } from "../db.js" +import { + nominationApprovals, + nominations, + type Nomination +} from "../db/schema.js" + +export type NominationStatus = "submitted" | "approved" + +type CreateNominationInput = { + guildId: string + channelId: string + nomineeId: string + nominatorId: string + reason: string + targetRoleId: string + requiredApprovals: number +} + +const now = sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` + +export const createNomination = async ( + input: CreateNominationInput +): Promise => { + const [nomination] = await getDb() + .insert(nominations) + .values({ + ...input, + status: "submitted" + }) + .returning() + + return nomination +} + +export const getNomination = async (id: number): Promise => { + const [nomination] = await getDb() + .select() + .from(nominations) + .where(eq(nominations.id, id)) + .limit(1) + + return nomination ?? null +} + +export const getActiveNominationForNominee = async ( + guildId: string, + nomineeId: string, + targetRoleId: string +): Promise => { + const [nomination] = await getDb() + .select() + .from(nominations) + .where( + and( + eq(nominations.guildId, guildId), + eq(nominations.nomineeId, nomineeId), + eq(nominations.targetRoleId, targetRoleId), + eq(nominations.status, "submitted") + ) + ) + .limit(1) + + return nomination ?? null +} + +export const recordNominationApproval = async ( + nominationId: number, + approverId: string +): Promise => { + const [approval] = await getDb() + .insert(nominationApprovals) + .values({ + nominationId, + approverId + }) + .onConflictDoNothing({ + target: [nominationApprovals.nominationId, nominationApprovals.approverId] + }) + .returning() + + return Boolean(approval) +} + +export const getNominationApproverIds = async ( + nominationId: number +): Promise => { + const approvals = await getDb() + .select({ approverId: nominationApprovals.approverId }) + .from(nominationApprovals) + .where(eq(nominationApprovals.nominationId, nominationId)) + .orderBy(asc(nominationApprovals.createdAt), asc(nominationApprovals.id)) + + return approvals.map((approval) => approval.approverId) +} + +export const markNominationApproved = async ( + nominationId: number +): Promise => { + const [nomination] = await getDb() + .update(nominations) + .set({ + status: "approved", + completedAt: now, + updatedAt: now + }) + .where( + and( + eq(nominations.id, nominationId), + eq(nominations.status, "submitted") + ) + ) + .returning() + + return nomination ?? null +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 4976642..8049daa 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -220,6 +220,48 @@ export const claimRequests = sqliteTable( ] ) +export const nominations = sqliteTable( + "nominations", + { + id: integer().primaryKey({ autoIncrement: true }), + guildId: text("guild_id").notNull(), + channelId: text("channel_id").notNull(), + nomineeId: text("nominee_id").notNull(), + nominatorId: text("nominator_id").notNull(), + reason: text().notNull().default("No reason provided."), + targetRoleId: text("target_role_id").notNull(), + requiredApprovals: integer("required_approvals").notNull(), + status: text().notNull().default("submitted"), + completedAt: text("completed_at"), + createdAt: text("created_at") + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`) + }, + (table) => [ + index("idx_nominations_guild_nominee_status").on(table.guildId, table.nomineeId, table.status), + index("idx_nominations_status").on(table.status) + ] +) + +export const nominationApprovals = sqliteTable( + "nomination_approvals", + { + id: integer().primaryKey({ autoIncrement: true }), + nominationId: integer("nomination_id").notNull(), + approverId: text("approver_id").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`) + }, + (table) => [ + uniqueIndex("idx_nomination_approvals_nomination_approver").on(table.nominationId, table.approverId), + index("idx_nomination_approvals_nomination_id").on(table.nominationId) + ] +) + export type KeyValue = typeof keyValue.$inferSelect export type NewKeyValue = typeof keyValue.$inferInsert export type HelperEvent = typeof helperEvents.$inferSelect @@ -238,3 +280,7 @@ export type ClawhubContentRightsEvent = typeof clawhubContentRightsEvents.$infer export type NewClawhubContentRightsEvent = typeof clawhubContentRightsEvents.$inferInsert export type ClaimRequest = typeof claimRequests.$inferSelect export type NewClaimRequest = typeof claimRequests.$inferInsert +export type Nomination = typeof nominations.$inferSelect +export type NewNomination = typeof nominations.$inferInsert +export type NominationApproval = typeof nominationApprovals.$inferSelect +export type NewNominationApproval = typeof nominationApprovals.$inferInsert diff --git a/src/index.ts b/src/index.ts index 3431bf9..f2ec069 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import ClaimCommand from "./commands/claim.js" import GithubCommand from "./commands/github.js" import MaintainerCommand from "./commands/maintainer.js" import HelperRootCommand from "./commands/helper.js" +import NominateCommand from "./commands/nominate.js" import RoleCommand from "./commands/role.js" import SayRootCommand from "./commands/say.js" import SolvedModCommand from "./commands/solvedMod.js" @@ -20,6 +21,7 @@ import { formReviewModals } from "./forms/reviewButtons.js" import { fscRequestComponents } from "./components/fscRequestButtons.js" +import { nominationComponents } from "./components/nominationButtons.js" import { whoisDeleteComponents } from "./components/whoisDeleteButton.js" import { hydrateRuntimeEnv, type HermitEnv } from "./runtime/env.js" import { @@ -53,6 +55,7 @@ export const client = new Client( new RoleCommand(), new HelperRootCommand(), new ClaimCommand(), + new NominateCommand(), new MaintainerCommand(), new AdminCommand() ], @@ -69,6 +72,7 @@ export const client = new Client( ...claimReviewComponents, ...formReviewComponents, ...fscRequestComponents, + ...nominationComponents, ...whoisDeleteComponents ], modals: [...claimReviewModals, ...formReviewModals] diff --git a/tests/nominations.test.ts b/tests/nominations.test.ts new file mode 100644 index 0000000..6a0c307 --- /dev/null +++ b/tests/nominations.test.ts @@ -0,0 +1,97 @@ +import { Database } from "bun:sqlite" +import { describe, expect, it } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" + +const nominationMigrationPaths = readdirSync("drizzle") + .filter((file) => /000[45]_.*\.sql/.test(file)) + .sort() + +if (nominationMigrationPaths.length !== 2) { + throw new Error("Could not find nomination migrations") +} + +const applyMigration = (database: Database, path: string) => { + const migration = readFileSync(path, "utf8") + for (const statement of migration.split("--> statement-breakpoint")) { + const trimmed = statement.trim() + if (trimmed.length > 0) { + database.run(trimmed) + } + } +} + +const createNomination = (database: Database) => { + database.run( + `insert into nominations ( + guild_id, + channel_id, + nominee_id, + nominator_id, + reason, + target_role_id, + required_approvals, + status + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "guild-1", + "channel-1", + "nominee-1", + "nominator-1", + "excellent shell judgment", + "role-1", + 2, + "submitted" + ] + ) + + return Number(database.query("select last_insert_rowid() as id").get()?.id) +} + +describe("nomination migration", () => { + it("allows two distinct approvers for one nomination", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths) { + applyMigration(database, `drizzle/${migrationPath}`) + } + const nominationId = createNomination(database) + + database.run( + "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", + [nominationId, "approver-1"] + ) + database.run( + "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", + [nominationId, "approver-2"] + ) + + const row = database + .query("select count(*) as count from nomination_approvals") + .get() as { count: number } + const nomination = database + .query("select reason from nominations where id = ?") + .get(nominationId) as { reason: string } + + expect(row.count).toBe(2) + expect(nomination.reason).toBe("excellent shell judgment") + }) + + it("rejects duplicate approval from the same approver", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths) { + applyMigration(database, `drizzle/${migrationPath}`) + } + const nominationId = createNomination(database) + + database.run( + "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", + [nominationId, "approver-1"] + ) + + expect(() => + database.run( + "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", + [nominationId, "approver-1"] + ) + ).toThrow() + }) +}) From d907d41160d04c374d52aa2c9981cd97f3d9c204 Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:58:03 -0400 Subject: [PATCH 2/6] Update Shell Society approvals to three --- src/config/nominations.ts | 2 +- tests/nominations.test.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/config/nominations.ts b/src/config/nominations.ts index 5d67e4b..14e86cc 100644 --- a/src/config/nominations.ts +++ b/src/config/nominations.ts @@ -3,7 +3,7 @@ export const nominationConfig = { nominationChannelIds: ["1471742293055635536"], approverRoleIds: ["1477360613125787678"], targetRoleId: "1470356404706607227", - requiredApprovals: 2, + requiredApprovals: 3, commandName: "nominate", copy: { title: "🐚🐚 Society nomination! 🐚🐚", diff --git a/tests/nominations.test.ts b/tests/nominations.test.ts index 6a0c307..f602483 100644 --- a/tests/nominations.test.ts +++ b/tests/nominations.test.ts @@ -39,7 +39,7 @@ const createNomination = (database: Database) => { "nominator-1", "excellent shell judgment", "role-1", - 2, + 3, "submitted" ] ) @@ -48,7 +48,7 @@ const createNomination = (database: Database) => { } describe("nomination migration", () => { - it("allows two distinct approvers for one nomination", () => { + it("allows three distinct approvers for one nomination", () => { const database = new Database(":memory:") for (const migrationPath of nominationMigrationPaths) { applyMigration(database, `drizzle/${migrationPath}`) @@ -63,6 +63,10 @@ describe("nomination migration", () => { "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", [nominationId, "approver-2"] ) + database.run( + "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", + [nominationId, "approver-3"] + ) const row = database .query("select count(*) as count from nomination_approvals") @@ -71,7 +75,7 @@ describe("nomination migration", () => { .query("select reason from nominations where id = ?") .get(nominationId) as { reason: string } - expect(row.count).toBe(2) + expect(row.count).toBe(3) expect(nomination.reason).toBe("excellent shell judgment") }) From cf869c5285cf8fcc3ad8eb5d910b37abcd782f2d Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:53 -0400 Subject: [PATCH 3/6] Fix Shell Society nomination review issues --- drizzle/0006_clean_prism.sql | 2 + drizzle/meta/0006_snapshot.json | 1151 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/commands/nominate.ts | 112 ++- src/components/nominationButtons.ts | 66 +- src/config/nominations.ts | 7 +- src/data/nominations.ts | 9 +- src/db/schema.ts | 4 +- tests/nominations.test.ts | 54 +- 9 files changed, 1345 insertions(+), 67 deletions(-) create mode 100644 drizzle/0006_clean_prism.sql create mode 100644 drizzle/meta/0006_snapshot.json diff --git a/drizzle/0006_clean_prism.sql b/drizzle/0006_clean_prism.sql new file mode 100644 index 0000000..3580d07 --- /dev/null +++ b/drizzle/0006_clean_prism.sql @@ -0,0 +1,2 @@ +DROP INDEX `idx_nominations_guild_nominee_status`;--> statement-breakpoint +CREATE UNIQUE INDEX `idx_nominations_submitted_unique` ON `nominations` (`guild_id`,`nominee_id`,`target_role_id`) WHERE "nominations"."status" = 'submitted'; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..aeaf33a --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1151 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8881e918-51a0-4d28-bb4c-0e9573a64c91", + "prevId": "b40ffb14-0380-48b0-90ab-1a97e3fbb7c0", + "tables": { + "claim_requests": { + "name": "claim_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merged_pr_count": { + "name": "merged_pr_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_claim_requests_guild_user": { + "name": "idx_claim_requests_guild_user", + "columns": [ + "guild_id", + "user_id" + ], + "isUnique": true + }, + "idx_claim_requests_user_id": { + "name": "idx_claim_requests_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_claim_requests_status": { + "name": "idx_claim_requests_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_cases": { + "name": "clawhub_content_rights_cases", + "columns": { + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_submission_id": { + "name": "form_submission_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "requester_name": { + "name": "requester_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clawhub_urls": { + "name": "clawhub_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_cases_form_submission_id_unique": { + "name": "clawhub_content_rights_cases_form_submission_id_unique", + "columns": [ + "form_submission_id" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_cases_status": { + "name": "idx_clawhub_content_rights_cases_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_cases_email": { + "name": "idx_clawhub_content_rights_cases_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_events": { + "name": "clawhub_content_rights_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_clawhub_content_rights_events_case_id": { + "name": "idx_clawhub_content_rights_events_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_events_event_type": { + "name": "idx_clawhub_content_rights_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_files": { + "name": "clawhub_content_rights_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_files_object_key_unique": { + "name": "clawhub_content_rights_files_object_key_unique", + "columns": [ + "object_key" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_files_case_id": { + "name": "idx_clawhub_content_rights_files_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_submissions": { + "name": "form_submissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_id": { + "name": "applicant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_username": { + "name": "applicant_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_channel_id": { + "name": "review_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action_result": { + "name": "action_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_form_submissions_form_id": { + "name": "idx_form_submissions_form_id", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "idx_form_submissions_status": { + "name": "idx_form_submissions_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_form_submissions_applicant_id": { + "name": "idx_form_submissions_applicant_id", + "columns": [ + "applicant_id" + ], + "isUnique": false + }, + "idx_form_submissions_review_message_id": { + "name": "idx_form_submissions_review_message_id", + "columns": [ + "review_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "helper_events": { + "name": "helper_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'helper_command'" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_time": { + "name": "event_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoked_by_id": { + "name": "invoked_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_username": { + "name": "invoked_by_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_global_name": { + "name": "invoked_by_global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_helper_events_event_time": { + "name": "idx_helper_events_event_time", + "columns": [ + "event_time" + ], + "isUnique": false + }, + "idx_helper_events_command": { + "name": "idx_helper_events_command", + "columns": [ + "command" + ], + "isUnique": false + }, + "idx_helper_events_thread_id": { + "name": "idx_helper_events_thread_id", + "columns": [ + "thread_id" + ], + "isUnique": false + }, + "idx_helper_events_invoked_by_id": { + "name": "idx_helper_events_invoked_by_id", + "columns": [ + "invoked_by_id" + ], + "isUnique": false + }, + "idx_helper_events_event_type": { + "name": "idx_helper_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + }, + "idx_helper_events_thread_time": { + "name": "idx_helper_events_thread_time", + "columns": [ + "thread_id", + "event_time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyValue": { + "name": "keyValue", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nomination_approvals": { + "name": "nomination_approvals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "nomination_id": { + "name": "nomination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approver_id": { + "name": "approver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nomination_approvals_nomination_approver": { + "name": "idx_nomination_approvals_nomination_approver", + "columns": [ + "nomination_id", + "approver_id" + ], + "isUnique": true + }, + "idx_nomination_approvals_nomination_id": { + "name": "idx_nomination_approvals_nomination_id", + "columns": [ + "nomination_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nominations": { + "name": "nominations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominee_id": { + "name": "nominee_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominator_id": { + "name": "nominator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'No reason provided.'" + }, + "target_role_id": { + "name": "target_role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_approvals": { + "name": "required_approvals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nominations_submitted_unique": { + "name": "idx_nominations_submitted_unique", + "columns": [ + "guild_id", + "nominee_id", + "target_role_id" + ], + "isUnique": true, + "where": "\"nominations\".\"status\" = 'submitted'" + }, + "idx_nominations_status": { + "name": "idx_nominations_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_moderation_contexts": { + "name": "reddit_moderation_contexts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'moderated'" + }, + "unaction": { + "name": "unaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reviewed'" + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderator": { + "name": "moderator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_at": { + "name": "banned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_reddit_moderation_contexts_subreddit_username": { + "name": "idx_reddit_moderation_contexts_subreddit_username", + "columns": [ + "subreddit", + "username" + ], + "isUnique": true + }, + "idx_reddit_moderation_contexts_username": { + "name": "idx_reddit_moderation_contexts_username", + "columns": [ + "username" + ], + "isUnique": false + }, + "idx_reddit_moderation_contexts_action": { + "name": "idx_reddit_moderation_contexts_action", + "columns": [ + "action" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracked_threads": { + "name": "tracked_threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "solved": { + "name": "solved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "warning_level": { + "name": "warning_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_message_count": { + "name": "last_message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tracked_threads_thread_id_unique": { + "name": "tracked_threads_thread_id_unique", + "columns": [ + "thread_id" + ], + "isUnique": true + }, + "idx_tracked_threads_solved": { + "name": "idx_tracked_threads_solved", + "columns": [ + "solved" + ], + "isUnique": false + }, + "idx_tracked_threads_last_checked": { + "name": "idx_tracked_threads_last_checked", + "columns": [ + "last_checked" + ], + "isUnique": false + }, + "idx_tracked_threads_received_at": { + "name": "idx_tracked_threads_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + }, + "idx_tracked_threads_closed": { + "name": "idx_tracked_threads_closed", + "columns": [ + "closed" + ], + "isUnique": false + }, + "idx_tracked_threads_warning_level": { + "name": "idx_tracked_threads_warning_level", + "columns": [ + "warning_level" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cee61ba..e2f9fab 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782184143387, "tag": "0005_lethal_senator_kelly", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1782242214106, + "tag": "0006_clean_prism", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/commands/nominate.ts b/src/commands/nominate.ts index f30c8b0..5bb95ab 100644 --- a/src/commands/nominate.ts +++ b/src/commands/nominate.ts @@ -4,10 +4,14 @@ import { type CommandInteraction, InteractionContextType } from "@buape/carbon" -import { buildNominationContainer } from "../components/nominationButtons.js" +import { + buildNominationContainer, + buildNominationNoticeContainer +} from "../components/nominationButtons.js" import { nominationConfig } from "../config/nominations.js" import { createNomination, + deleteNomination, getActiveNominationForNominee, getNominationApproverIds } from "../data/nominations.js" @@ -16,6 +20,7 @@ import BaseCommand from "./base.js" export default class NominateCommand extends BaseCommand { name = nominationConfig.commandName description = "Nominate a user for Shell Society" + defer = false contexts = [InteractionContextType.Guild] integrationTypes = [ApplicationIntegrationType.GuildInstall] options = [ @@ -29,18 +34,27 @@ export default class NominateCommand extends BaseCommand { type: ApplicationCommandOptionType.String as const, name: "reason", description: "Why this user should join Shell Society", - required: true + required: true, + max_length: nominationConfig.maxReasonLength } ] + private async replyWithNotice( + interaction: CommandInteraction, + body: string, + accentColor = "#f1c40f" + ) { + await interaction.reply({ + components: [buildNominationNoticeContainer(body, accentColor)], + ephemeral: true, + allowedMentions: { parse: [] } + }) + } + async run(interaction: CommandInteraction) { const channelId = interaction.rawData.channel_id ?? interaction.channel?.id if (!channelId || !nominationConfig.nominationChannelIds.includes(channelId)) { - await interaction.reply({ - content: nominationConfig.copy.wrongChannel, - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice(interaction, nominationConfig.copy.wrongChannel) return } @@ -49,50 +63,48 @@ export default class NominateCommand extends BaseCommand { } const target = interaction.options.getUser("user", true) - const reason = interaction.options.getString("reason", true).trim() + let reasonOption: string | undefined + try { + reasonOption = interaction.options.getString("reason") + } catch { + await this.replyWithNotice(interaction, nominationConfig.copy.reasonTooLong) + return + } + const reason = reasonOption?.trim() ?? "" if (reason.length === 0) { - await interaction.reply({ - content: nominationConfig.copy.reasonRequired, - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice(interaction, nominationConfig.copy.reasonRequired) + return + } + + if (reason.length > nominationConfig.maxReasonLength) { + await this.replyWithNotice(interaction, nominationConfig.copy.reasonTooLong) return } if (target.id === interaction.user.id) { - await interaction.reply({ - content: nominationConfig.copy.selfNomination, - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice(interaction, nominationConfig.copy.selfNomination) return } if (target.bot) { - await interaction.reply({ - content: nominationConfig.copy.botNomination, - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice(interaction, nominationConfig.copy.botNomination) return } + await interaction.defer({ ephemeral: true }) + const targetMember = await interaction.guild.fetchMember(target.id).catch(() => null) if (!targetMember) { - await interaction.reply({ - content: "User not found in the server.", - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice( + interaction, + nominationConfig.copy.userNotFound, + "#f85149" + ) return } if (targetMember.roles.some((role) => role.id === nominationConfig.targetRoleId)) { - await interaction.reply({ - content: nominationConfig.copy.alreadyHasRole, - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice(interaction, nominationConfig.copy.alreadyHasRole) return } @@ -102,11 +114,7 @@ export default class NominateCommand extends BaseCommand { nominationConfig.targetRoleId ) if (existingNomination) { - await interaction.reply({ - content: nominationConfig.copy.alreadyPending, - ephemeral: true, - allowedMentions: { parse: [] } - }) + await this.replyWithNotice(interaction, nominationConfig.copy.alreadyPending) return } @@ -119,11 +127,31 @@ export default class NominateCommand extends BaseCommand { targetRoleId: nominationConfig.targetRoleId, requiredApprovals: nominationConfig.requiredApprovals }) + if (!nomination) { + await this.replyWithNotice(interaction, nominationConfig.copy.alreadyPending) + return + } const approverIds = await getNominationApproverIds(nomination.id) - await interaction.reply({ - components: [buildNominationContainer(nomination, approverIds)], - allowedMentions: { parse: [] } - }) + try { + await interaction.followUp({ + components: [buildNominationContainer(nomination, approverIds)], + allowedMentions: { parse: [] } + }) + } catch { + await deleteNomination(nomination.id).catch(() => null) + await this.replyWithNotice( + interaction, + nominationConfig.copy.nominationPostFailed, + "#f85149" + ).catch(() => null) + return + } + + await this.replyWithNotice( + interaction, + nominationConfig.copy.nominationPosted, + "#3fb950" + ) } } diff --git a/src/components/nominationButtons.ts b/src/components/nominationButtons.ts index 3c6316b..ee40f5a 100644 --- a/src/components/nominationButtons.ts +++ b/src/components/nominationButtons.ts @@ -35,6 +35,11 @@ const hasApproverRole = (interaction: ButtonInteraction) => nominationConfig.approverRoleIds.includes(role.id) ) ?? false +export const buildNominationNoticeContainer = ( + body: string, + accentColor = "#f1c40f" +) => new Container([new TextDisplay(body)], { accentColor }) + const addTargetRole = async (nomination: Nomination) => { const roleResponse = await fetch( `${discordApiBase}/guilds/${nomination.guildId}/members/${nomination.nomineeId}/roles/${nomination.targetRoleId}`, @@ -93,7 +98,12 @@ export class NominationApproveButton extends Button { const id = parseNominationId(data.id) if (!id) { await interaction.reply({ - content: nominationConfig.copy.invalidNomination, + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.invalidNomination, + "#f85149" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -103,7 +113,12 @@ export class NominationApproveButton extends Button { const nomination = await getNomination(id) if (!nomination) { await interaction.reply({ - content: nominationConfig.copy.invalidNomination, + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.invalidNomination, + "#f85149" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -112,7 +127,9 @@ export class NominationApproveButton extends Button { if (nomination.status === "approved") { await interaction.reply({ - content: nominationConfig.copy.alreadyComplete, + components: [ + buildNominationNoticeContainer(nominationConfig.copy.alreadyComplete) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -121,7 +138,12 @@ export class NominationApproveButton extends Button { if (!hasApproverRole(interaction)) { await interaction.reply({ - content: nominationConfig.copy.noPermission, + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.noPermission, + "#f85149" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -131,7 +153,12 @@ export class NominationApproveButton extends Button { const approverId = interaction.user?.id ?? interaction.userId if (!approverId) { await interaction.reply({ - content: nominationConfig.copy.invalidNomination, + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.invalidNomination, + "#f85149" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -142,7 +169,9 @@ export class NominationApproveButton extends Button { const approverIds = await getNominationApproverIds(nomination.id) if (!recorded && approverIds.length < nomination.requiredApprovals) { await interaction.reply({ - content: nominationConfig.copy.alreadyApproved, + components: [ + buildNominationNoticeContainer(nominationConfig.copy.alreadyApproved) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -155,7 +184,12 @@ export class NominationApproveButton extends Button { allowedMentions: { parse: [] } }).catch(() => null) await interaction.reply({ - content: `${nominationConfig.copy.approvalRecorded} ${approverIds.length}/${nomination.requiredApprovals}.`, + components: [ + buildNominationNoticeContainer( + `${nominationConfig.copy.approvalRecorded} ${approverIds.length}/${nomination.requiredApprovals}.`, + "#3fb950" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -168,7 +202,12 @@ export class NominationApproveButton extends Button { allowedMentions: { parse: [] } }).catch(() => null) await interaction.reply({ - content: nominationConfig.copy.roleAddFailed, + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.roleAddFailed, + "#f85149" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -178,7 +217,9 @@ export class NominationApproveButton extends Button { const approvedNomination = await markNominationApproved(nomination.id) if (!approvedNomination) { await interaction.reply({ - content: nominationConfig.copy.alreadyComplete, + components: [ + buildNominationNoticeContainer(nominationConfig.copy.alreadyComplete) + ], ephemeral: true, allowedMentions: { parse: [] } }) @@ -190,7 +231,12 @@ export class NominationApproveButton extends Button { allowedMentions: { parse: [] } }).catch(() => null) await interaction.reply({ - content: nominationConfig.copy.approvalRecorded, + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.approvalRecorded, + "#3fb950" + ) + ], ephemeral: true, allowedMentions: { parse: [] } }) diff --git a/src/config/nominations.ts b/src/config/nominations.ts index 14e86cc..35ea063 100644 --- a/src/config/nominations.ts +++ b/src/config/nominations.ts @@ -3,7 +3,8 @@ export const nominationConfig = { nominationChannelIds: ["1471742293055635536"], approverRoleIds: ["1477360613125787678"], targetRoleId: "1470356404706607227", - requiredApprovals: 3, + requiredApprovals: 2, + maxReasonLength: 500, commandName: "nominate", copy: { title: "🐚🐚 Society nomination! 🐚🐚", @@ -11,12 +12,16 @@ export const nominationConfig = { wrongChannel: "This command can only be used in a secret channel... 🐚", selfNomination: "You cannot nominate yourself.", reasonRequired: "Reason required. The shell demands context.", + reasonTooLong: "Please keep the reason to 500 characters or fewer.", botNomination: "Bots cannot receive the shell. They know what they did.", + userNotFound: "User not found in the server.", alreadyHasRole: "That user already has Shell Society.", alreadyPending: "That user already has an open Shell Society nomination.", noPermission: "Community Team only. Nice try though.", alreadyApproved: "You already approved this one. The shell remembers.", approvalRecorded: "Approval recorded.", + nominationPosted: "Nomination posted.", + nominationPostFailed: "Could not post the nomination. Please try again.", alreadyComplete: "This nomination is already complete.", roleAddFailed: "Could not add Shell Society. Check bot permissions and role order.", invalidNomination: "Could not load this nomination." diff --git a/src/data/nominations.ts b/src/data/nominations.ts index 826a8b5..a602415 100644 --- a/src/data/nominations.ts +++ b/src/data/nominations.ts @@ -22,16 +22,17 @@ const now = sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` export const createNomination = async ( input: CreateNominationInput -): Promise => { +): Promise => { const [nomination] = await getDb() .insert(nominations) .values({ ...input, status: "submitted" }) + .onConflictDoNothing() .returning() - return nomination + return nomination ?? null } export const getNomination = async (id: number): Promise => { @@ -44,6 +45,10 @@ export const getNomination = async (id: number): Promise => { return nomination ?? null } +export const deleteNomination = async (nominationId: number): Promise => { + await getDb().delete(nominations).where(eq(nominations.id, nominationId)) +} + export const getActiveNominationForNominee = async ( guildId: string, nomineeId: string, diff --git a/src/db/schema.ts b/src/db/schema.ts index 8049daa..266aec4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -241,7 +241,9 @@ export const nominations = sqliteTable( .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`) }, (table) => [ - index("idx_nominations_guild_nominee_status").on(table.guildId, table.nomineeId, table.status), + uniqueIndex("idx_nominations_submitted_unique") + .on(table.guildId, table.nomineeId, table.targetRoleId) + .where(sql`${table.status} = 'submitted'`), index("idx_nominations_status").on(table.status) ] ) diff --git a/tests/nominations.test.ts b/tests/nominations.test.ts index f602483..600c56d 100644 --- a/tests/nominations.test.ts +++ b/tests/nominations.test.ts @@ -1,12 +1,13 @@ import { Database } from "bun:sqlite" import { describe, expect, it } from "bun:test" import { readdirSync, readFileSync } from "node:fs" +import { nominationConfig } from "../src/config/nominations.js" const nominationMigrationPaths = readdirSync("drizzle") - .filter((file) => /000[45]_.*\.sql/.test(file)) + .filter((file) => /000[456]_.*\.sql/.test(file)) .sort() -if (nominationMigrationPaths.length !== 2) { +if (nominationMigrationPaths.length !== 3) { throw new Error("Could not find nomination migrations") } @@ -39,7 +40,7 @@ const createNomination = (database: Database) => { "nominator-1", "excellent shell judgment", "role-1", - 3, + 2, "submitted" ] ) @@ -48,7 +49,12 @@ const createNomination = (database: Database) => { } describe("nomination migration", () => { - it("allows three distinct approvers for one nomination", () => { + it("requires two configured approvals", () => { + expect(nominationConfig.requiredApprovals).toBe(2) + expect(nominationConfig.maxReasonLength).toBeLessThanOrEqual(500) + }) + + it("allows two distinct approvers for one nomination", () => { const database = new Database(":memory:") for (const migrationPath of nominationMigrationPaths) { applyMigration(database, `drizzle/${migrationPath}`) @@ -63,20 +69,17 @@ describe("nomination migration", () => { "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", [nominationId, "approver-2"] ) - database.run( - "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", - [nominationId, "approver-3"] - ) const row = database .query("select count(*) as count from nomination_approvals") .get() as { count: number } const nomination = database - .query("select reason from nominations where id = ?") - .get(nominationId) as { reason: string } + .query("select reason, required_approvals as requiredApprovals from nominations where id = ?") + .get(nominationId) as { reason: string; requiredApprovals: number } - expect(row.count).toBe(3) + expect(row.count).toBe(2) expect(nomination.reason).toBe("excellent shell judgment") + expect(nomination.requiredApprovals).toBe(2) }) it("rejects duplicate approval from the same approver", () => { @@ -98,4 +101,33 @@ describe("nomination migration", () => { ) ).toThrow() }) + + it("allows only one submitted nomination per nominee and target role", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths) { + applyMigration(database, `drizzle/${migrationPath}`) + } + createNomination(database) + + expect(() => createNomination(database)).toThrow() + }) + + it("allows a new nomination after the previous nomination is approved", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths) { + applyMigration(database, `drizzle/${migrationPath}`) + } + const nominationId = createNomination(database) + database.run("update nominations set status = ? where id = ?", [ + "approved", + nominationId + ]) + + createNomination(database) + + const row = database + .query("select count(*) as count from nominations") + .get() as { count: number } + expect(row.count).toBe(2) + }) }) From 3b3b2ccf203d97ff6643e00a7506f89fed9e75dd Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:25:07 -0400 Subject: [PATCH 4/6] Require three Shell Society approvals --- src/config/nominations.ts | 2 +- tests/nominations.test.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/config/nominations.ts b/src/config/nominations.ts index 35ea063..48785cb 100644 --- a/src/config/nominations.ts +++ b/src/config/nominations.ts @@ -3,7 +3,7 @@ export const nominationConfig = { nominationChannelIds: ["1471742293055635536"], approverRoleIds: ["1477360613125787678"], targetRoleId: "1470356404706607227", - requiredApprovals: 2, + requiredApprovals: 3, maxReasonLength: 500, commandName: "nominate", copy: { diff --git a/tests/nominations.test.ts b/tests/nominations.test.ts index 600c56d..6c1875f 100644 --- a/tests/nominations.test.ts +++ b/tests/nominations.test.ts @@ -40,7 +40,7 @@ const createNomination = (database: Database) => { "nominator-1", "excellent shell judgment", "role-1", - 2, + 3, "submitted" ] ) @@ -49,12 +49,12 @@ const createNomination = (database: Database) => { } describe("nomination migration", () => { - it("requires two configured approvals", () => { - expect(nominationConfig.requiredApprovals).toBe(2) + it("requires three configured approvals", () => { + expect(nominationConfig.requiredApprovals).toBe(3) expect(nominationConfig.maxReasonLength).toBeLessThanOrEqual(500) }) - it("allows two distinct approvers for one nomination", () => { + it("allows three distinct approvers for one nomination", () => { const database = new Database(":memory:") for (const migrationPath of nominationMigrationPaths) { applyMigration(database, `drizzle/${migrationPath}`) @@ -69,6 +69,10 @@ describe("nomination migration", () => { "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", [nominationId, "approver-2"] ) + database.run( + "insert into nomination_approvals (nomination_id, approver_id) values (?, ?)", + [nominationId, "approver-3"] + ) const row = database .query("select count(*) as count from nomination_approvals") @@ -77,9 +81,9 @@ describe("nomination migration", () => { .query("select reason, required_approvals as requiredApprovals from nominations where id = ?") .get(nominationId) as { reason: string; requiredApprovals: number } - expect(row.count).toBe(2) + expect(row.count).toBe(3) expect(nomination.reason).toBe("excellent shell judgment") - expect(nomination.requiredApprovals).toBe(2) + expect(nomination.requiredApprovals).toBe(3) }) it("rejects duplicate approval from the same approver", () => { From b924cef8e8f50c324ac6d04a52c5509dae65b3cf Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:01:47 -0400 Subject: [PATCH 5/6] Expire Shell Society nominations --- drizzle/0007_overrated_mauler.sql | 3 + drizzle/meta/0007_snapshot.json | 1165 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/commands/nominate.ts | 52 +- src/components/nominationButtons.ts | 113 ++- src/config/nominations.ts | 2 + src/data/nominations.ts | 135 +++- src/db/schema.ts | 2 + src/index.ts | 8 +- src/services/nominationExpiry.ts | 52 ++ tests/nominations.test.ts | 114 ++- wrangler.jsonc | 2 +- 12 files changed, 1615 insertions(+), 40 deletions(-) create mode 100644 drizzle/0007_overrated_mauler.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 src/services/nominationExpiry.ts diff --git a/drizzle/0007_overrated_mauler.sql b/drizzle/0007_overrated_mauler.sql new file mode 100644 index 0000000..ffe6280 --- /dev/null +++ b/drizzle/0007_overrated_mauler.sql @@ -0,0 +1,3 @@ +ALTER TABLE `nominations` ADD `message_id` text;--> statement-breakpoint +ALTER TABLE `nominations` ADD `expires_at` text;--> statement-breakpoint +UPDATE `nominations` SET `expires_at` = strftime('%Y-%m-%dT%H:%M:%fZ', `created_at`, '+48 hours') WHERE `expires_at` IS NULL; diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..a966acb --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1165 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2a3dc470-7bab-4d8f-ab95-5ba6b8a4e11c", + "prevId": "8881e918-51a0-4d28-bb4c-0e9573a64c91", + "tables": { + "claim_requests": { + "name": "claim_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merged_pr_count": { + "name": "merged_pr_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_claim_requests_guild_user": { + "name": "idx_claim_requests_guild_user", + "columns": [ + "guild_id", + "user_id" + ], + "isUnique": true + }, + "idx_claim_requests_user_id": { + "name": "idx_claim_requests_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_claim_requests_status": { + "name": "idx_claim_requests_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_cases": { + "name": "clawhub_content_rights_cases", + "columns": { + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_submission_id": { + "name": "form_submission_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "requester_name": { + "name": "requester_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clawhub_urls": { + "name": "clawhub_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_cases_form_submission_id_unique": { + "name": "clawhub_content_rights_cases_form_submission_id_unique", + "columns": [ + "form_submission_id" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_cases_status": { + "name": "idx_clawhub_content_rights_cases_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_cases_email": { + "name": "idx_clawhub_content_rights_cases_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_events": { + "name": "clawhub_content_rights_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_clawhub_content_rights_events_case_id": { + "name": "idx_clawhub_content_rights_events_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + }, + "idx_clawhub_content_rights_events_event_type": { + "name": "idx_clawhub_content_rights_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clawhub_content_rights_files": { + "name": "clawhub_content_rights_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "clawhub_content_rights_files_object_key_unique": { + "name": "clawhub_content_rights_files_object_key_unique", + "columns": [ + "object_key" + ], + "isUnique": true + }, + "idx_clawhub_content_rights_files_case_id": { + "name": "idx_clawhub_content_rights_files_case_id", + "columns": [ + "case_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_submissions": { + "name": "form_submissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_id": { + "name": "applicant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applicant_username": { + "name": "applicant_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_channel_id": { + "name": "review_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_message_id": { + "name": "review_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_thread_id": { + "name": "review_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action_result": { + "name": "action_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_form_submissions_form_id": { + "name": "idx_form_submissions_form_id", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "idx_form_submissions_status": { + "name": "idx_form_submissions_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_form_submissions_applicant_id": { + "name": "idx_form_submissions_applicant_id", + "columns": [ + "applicant_id" + ], + "isUnique": false + }, + "idx_form_submissions_review_message_id": { + "name": "idx_form_submissions_review_message_id", + "columns": [ + "review_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "helper_events": { + "name": "helper_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'helper_command'" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_time": { + "name": "event_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoked_by_id": { + "name": "invoked_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_username": { + "name": "invoked_by_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoked_by_global_name": { + "name": "invoked_by_global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_helper_events_event_time": { + "name": "idx_helper_events_event_time", + "columns": [ + "event_time" + ], + "isUnique": false + }, + "idx_helper_events_command": { + "name": "idx_helper_events_command", + "columns": [ + "command" + ], + "isUnique": false + }, + "idx_helper_events_thread_id": { + "name": "idx_helper_events_thread_id", + "columns": [ + "thread_id" + ], + "isUnique": false + }, + "idx_helper_events_invoked_by_id": { + "name": "idx_helper_events_invoked_by_id", + "columns": [ + "invoked_by_id" + ], + "isUnique": false + }, + "idx_helper_events_event_type": { + "name": "idx_helper_events_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + }, + "idx_helper_events_thread_time": { + "name": "idx_helper_events_thread_time", + "columns": [ + "thread_id", + "event_time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyValue": { + "name": "keyValue", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nomination_approvals": { + "name": "nomination_approvals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "nomination_id": { + "name": "nomination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approver_id": { + "name": "approver_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nomination_approvals_nomination_approver": { + "name": "idx_nomination_approvals_nomination_approver", + "columns": [ + "nomination_id", + "approver_id" + ], + "isUnique": true + }, + "idx_nomination_approvals_nomination_id": { + "name": "idx_nomination_approvals_nomination_id", + "columns": [ + "nomination_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nominations": { + "name": "nominations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominee_id": { + "name": "nominee_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nominator_id": { + "name": "nominator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'No reason provided.'" + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_role_id": { + "name": "target_role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_approvals": { + "name": "required_approvals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'submitted'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_nominations_submitted_unique": { + "name": "idx_nominations_submitted_unique", + "columns": [ + "guild_id", + "nominee_id", + "target_role_id" + ], + "isUnique": true, + "where": "\"nominations\".\"status\" = 'submitted'" + }, + "idx_nominations_status": { + "name": "idx_nominations_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_moderation_contexts": { + "name": "reddit_moderation_contexts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'moderated'" + }, + "unaction": { + "name": "unaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reviewed'" + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderator": { + "name": "moderator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned_at": { + "name": "banned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "idx_reddit_moderation_contexts_subreddit_username": { + "name": "idx_reddit_moderation_contexts_subreddit_username", + "columns": [ + "subreddit", + "username" + ], + "isUnique": true + }, + "idx_reddit_moderation_contexts_username": { + "name": "idx_reddit_moderation_contexts_username", + "columns": [ + "username" + ], + "isUnique": false + }, + "idx_reddit_moderation_contexts_action": { + "name": "idx_reddit_moderation_contexts_action", + "columns": [ + "action" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracked_threads": { + "name": "tracked_threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "solved": { + "name": "solved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "warning_level": { + "name": "warning_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_message_count": { + "name": "last_message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tracked_threads_thread_id_unique": { + "name": "tracked_threads_thread_id_unique", + "columns": [ + "thread_id" + ], + "isUnique": true + }, + "idx_tracked_threads_solved": { + "name": "idx_tracked_threads_solved", + "columns": [ + "solved" + ], + "isUnique": false + }, + "idx_tracked_threads_last_checked": { + "name": "idx_tracked_threads_last_checked", + "columns": [ + "last_checked" + ], + "isUnique": false + }, + "idx_tracked_threads_received_at": { + "name": "idx_tracked_threads_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + }, + "idx_tracked_threads_closed": { + "name": "idx_tracked_threads_closed", + "columns": [ + "closed" + ], + "isUnique": false + }, + "idx_tracked_threads_warning_level": { + "name": "idx_tracked_threads_warning_level", + "columns": [ + "warning_level" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e2f9fab..33d670d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1782242214106, "tag": "0006_clean_prism", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1782243715774, + "tag": "0007_overrated_mauler", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/commands/nominate.ts b/src/commands/nominate.ts index 5bb95ab..9bc1d59 100644 --- a/src/commands/nominate.ts +++ b/src/commands/nominate.ts @@ -1,8 +1,11 @@ import { ApplicationCommandOptionType, ApplicationIntegrationType, + type APIMessage, type CommandInteraction, - InteractionContextType + InteractionContextType, + Routes, + serializePayload } from "@buape/carbon" import { buildNominationContainer, @@ -13,10 +16,18 @@ import { createNomination, deleteNomination, getActiveNominationForNominee, - getNominationApproverIds + getNominationApproverIds, + markExpiredSubmittedNominationForNominee, + setNominationMessageId } from "../data/nominations.js" +import { editNominationMessageExpired } from "../services/nominationExpiry.js" import BaseCommand from "./base.js" +const getNominationExpiresAt = () => + new Date( + Date.now() + nominationConfig.expirationHours * 60 * 60 * 1000 + ).toISOString() + export default class NominateCommand extends BaseCommand { name = nominationConfig.commandName description = "Nominate a user for Shell Society" @@ -93,6 +104,18 @@ export default class NominateCommand extends BaseCommand { await interaction.defer({ ephemeral: true }) + const expiredNomination = await markExpiredSubmittedNominationForNominee( + nominationConfig.guildId, + target.id, + nominationConfig.targetRoleId + ) + if (expiredNomination) { + await editNominationMessageExpired( + interaction.client, + expiredNomination + ).catch(() => null) + } + const targetMember = await interaction.guild.fetchMember(target.id).catch(() => null) if (!targetMember) { await this.replyWithNotice( @@ -124,6 +147,7 @@ export default class NominateCommand extends BaseCommand { nomineeId: target.id, nominatorId: interaction.user.id, reason, + expiresAt: getNominationExpiresAt(), targetRoleId: nominationConfig.targetRoleId, requiredApprovals: nominationConfig.requiredApprovals }) @@ -133,12 +157,28 @@ export default class NominateCommand extends BaseCommand { } const approverIds = await getNominationApproverIds(nomination.id) + let postedMessage: APIMessage | null = null try { - await interaction.followUp({ - components: [buildNominationContainer(nomination, approverIds)], - allowedMentions: { parse: [] } - }) + postedMessage = await interaction.client.rest.post( + Routes.webhook( + interaction.client.options.clientId, + interaction.rawData.token + ), + { + body: serializePayload({ + components: [buildNominationContainer(nomination, approverIds)], + allowedMentions: { parse: [] } + }) + }, + { wait: "true" } + ) as APIMessage + await setNominationMessageId(nomination.id, postedMessage.id) } catch { + if (postedMessage) { + await interaction.client.rest.delete( + Routes.channelMessage(channelId, postedMessage.id) + ).catch(() => null) + } await deleteNomination(nomination.id).catch(() => null) await this.replyWithNotice( interaction, diff --git a/src/components/nominationButtons.ts b/src/components/nominationButtons.ts index ee40f5a..cfd6553 100644 --- a/src/components/nominationButtons.ts +++ b/src/components/nominationButtons.ts @@ -12,8 +12,11 @@ import { nominationConfig } from "../config/nominations.js" import { getNomination, getNominationApproverIds, + isNominationExpired, markNominationApproved, - recordNominationApproval + markNominationExpired, + recordNominationApproval, + restoreApprovedNominationToSubmitted } from "../data/nominations.js" import type { Nomination } from "../db/schema.js" import { getRuntimeEnv } from "../runtime/env.js" @@ -59,22 +62,30 @@ export const buildNominationContainer = ( approverIds: string[] ) => { const approved = nomination.status === "approved" - const body = approved - ? `<@${nomination.nomineeId}> welcome to the Shell Society! + const expired = nomination.status === "expired" + const body = expired + ? nominationConfig.copy.nominationExpired + : approved + ? `<@${nomination.nomineeId}> welcome to the Shell Society! This is a private section of the server that is high signal, low noise, for the valued members of the server to gather together without the chaotic madness that is <#1456350065223270435>. Just remember, this is not a channel to share your PRs, etc; it’s only a social channel so please treat it as such and above all else, enjoy! 🐚🦞` - : `<@${nomination.nomineeId}> has been nominated by <@${nomination.nominatorId}> for ${nomination.reason}.` - - return new Container( - [ - new TextDisplay(`### ${nominationConfig.copy.title}`), - new TextDisplay(body), + : `<@${nomination.nomineeId}> has been nominated by <@${nomination.nominatorId}> for ${nomination.reason}.` + const components: Container["components"] = [ + new TextDisplay(`### ${nominationConfig.copy.title}`), + new TextDisplay(body) + ] + if (!expired) { + components.push( new TextDisplay(`Approvals: ${Math.min(approverIds.length, nomination.requiredApprovals)}/${nomination.requiredApprovals}`), new Separator({ divider: true, spacing: "small" }), new Row([new NominationApproveButton(nomination.id, approved)]) - ], - { accentColor: approved ? "#3fb950" : "#f1c40f" } + ) + } + + return new Container( + components, + { accentColor: expired ? "#8b949e" : approved ? "#3fb950" : "#f1c40f" } ) } @@ -95,6 +106,30 @@ export class NominationApproveButton extends Button { } async run(interaction: ButtonInteraction, data: ComponentData) { + const replyWithExpired = async (nomination: Nomination) => { + const expiredNomination = + nomination.status === "expired" + ? nomination + : await markNominationExpired(nomination.id) ?? { + ...nomination, + status: "expired" + } + await interaction.message?.edit({ + components: [buildNominationContainer(expiredNomination, [])], + allowedMentions: { parse: [] } + }).catch(() => null) + await interaction.reply({ + components: [ + buildNominationNoticeContainer( + nominationConfig.copy.nominationExpired, + "#8b949e" + ) + ], + ephemeral: true, + allowedMentions: { parse: [] } + }) + } + const id = parseNominationId(data.id) if (!id) { await interaction.reply({ @@ -125,6 +160,11 @@ export class NominationApproveButton extends Button { return } + if (isNominationExpired(nomination)) { + await replyWithExpired(nomination) + return + } + if (nomination.status === "approved") { await interaction.reply({ components: [ @@ -196,17 +236,17 @@ export class NominationApproveButton extends Button { return } - if (!(await addTargetRole(nomination))) { - await interaction.message?.edit({ - components: [buildNominationContainer(nomination, approverIds)], - allowedMentions: { parse: [] } - }).catch(() => null) + const approvedNomination = await markNominationApproved(nomination.id) + if (!approvedNomination) { + const latestNomination = await getNomination(nomination.id) + if (latestNomination && isNominationExpired(latestNomination)) { + await replyWithExpired(latestNomination) + return + } + await interaction.reply({ components: [ - buildNominationNoticeContainer( - nominationConfig.copy.roleAddFailed, - "#f85149" - ) + buildNominationNoticeContainer(nominationConfig.copy.alreadyComplete) ], ephemeral: true, allowedMentions: { parse: [] } @@ -214,11 +254,36 @@ export class NominationApproveButton extends Button { return } - const approvedNomination = await markNominationApproved(nomination.id) - if (!approvedNomination) { + let roleAdded = false + try { + roleAdded = await addTargetRole(approvedNomination) + } catch (error) { + console.error( + `Failed to add role for nomination ${approvedNomination.id}:`, + error + ) + } + + if (!roleAdded) { + const restoredNomination = + await restoreApprovedNominationToSubmitted(approvedNomination.id) + if (restoredNomination && isNominationExpired(restoredNomination)) { + await replyWithExpired(restoredNomination) + return + } + + await interaction.message?.edit({ + components: [ + buildNominationContainer(restoredNomination ?? nomination, approverIds) + ], + allowedMentions: { parse: [] } + }).catch(() => null) await interaction.reply({ components: [ - buildNominationNoticeContainer(nominationConfig.copy.alreadyComplete) + buildNominationNoticeContainer( + nominationConfig.copy.roleAddFailed, + "#f85149" + ) ], ephemeral: true, allowedMentions: { parse: [] } @@ -240,8 +305,8 @@ export class NominationApproveButton extends Button { ephemeral: true, allowedMentions: { parse: [] } }) + } } -} export const nominationComponents = [ new NominationApproveButton() diff --git a/src/config/nominations.ts b/src/config/nominations.ts index 48785cb..1aa358c 100644 --- a/src/config/nominations.ts +++ b/src/config/nominations.ts @@ -4,6 +4,7 @@ export const nominationConfig = { approverRoleIds: ["1477360613125787678"], targetRoleId: "1470356404706607227", requiredApprovals: 3, + expirationHours: 48, maxReasonLength: 500, commandName: "nominate", copy: { @@ -22,6 +23,7 @@ export const nominationConfig = { approvalRecorded: "Approval recorded.", nominationPosted: "Nomination posted.", nominationPostFailed: "Could not post the nomination. Please try again.", + nominationExpired: "This nomination has expired.", alreadyComplete: "This nomination is already complete.", roleAddFailed: "Could not add Shell Society. Check bot permissions and role order.", invalidNomination: "Could not load this nomination." diff --git a/src/data/nominations.ts b/src/data/nominations.ts index a602415..8c091a1 100644 --- a/src/data/nominations.ts +++ b/src/data/nominations.ts @@ -1,4 +1,5 @@ -import { and, asc, eq, sql } from "drizzle-orm" +import { and, asc, eq, gt, lte, sql } from "drizzle-orm" +import { nominationConfig } from "../config/nominations.js" import { getDb } from "../db.js" import { nominationApprovals, @@ -6,7 +7,7 @@ import { type Nomination } from "../db/schema.js" -export type NominationStatus = "submitted" | "approved" +export type NominationStatus = "submitted" | "approved" | "expired" type CreateNominationInput = { guildId: string @@ -14,11 +15,30 @@ type CreateNominationInput = { nomineeId: string nominatorId: string reason: string + expiresAt: string targetRoleId: string requiredApprovals: number } const now = sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` +const fallbackExpirationModifier = `+${nominationConfig.expirationHours} hours` +const expiryDeadline = sql`coalesce( + ${nominations.expiresAt}, + strftime('%Y-%m-%dT%H:%M:%fZ', ${nominations.createdAt}, ${fallbackExpirationModifier}) +)` + +const getNominationExpiryTime = (nomination: Nomination) => { + if (nomination.expiresAt) { + return Date.parse(nomination.expiresAt) + } + + const createdAtTime = Date.parse(nomination.createdAt) + if (Number.isNaN(createdAtTime)) { + return Number.POSITIVE_INFINITY + } + + return createdAtTime + nominationConfig.expirationHours * 60 * 60 * 1000 +} export const createNomination = async ( input: CreateNominationInput @@ -49,6 +69,27 @@ export const deleteNomination = async (nominationId: number): Promise => { await getDb().delete(nominations).where(eq(nominations.id, nominationId)) } +export const setNominationMessageId = async ( + nominationId: number, + messageId: string +): Promise => { + await getDb() + .update(nominations) + .set({ + messageId, + updatedAt: now + }) + .where(eq(nominations.id, nominationId)) +} + +export const isNominationExpired = ( + nomination: Nomination, + referenceDate = new Date() +) => + nomination.status === "expired" || + (nomination.status === "submitted" && + getNominationExpiryTime(nomination) <= referenceDate.getTime()) + export const getActiveNominationForNominee = async ( guildId: string, nomineeId: string, @@ -62,7 +103,8 @@ export const getActiveNominationForNominee = async ( eq(nominations.guildId, guildId), eq(nominations.nomineeId, nomineeId), eq(nominations.targetRoleId, targetRoleId), - eq(nominations.status, "submitted") + eq(nominations.status, "submitted"), + gt(expiryDeadline, now) ) ) .limit(1) @@ -113,7 +155,92 @@ export const markNominationApproved = async ( .where( and( eq(nominations.id, nominationId), - eq(nominations.status, "submitted") + eq(nominations.status, "submitted"), + gt(expiryDeadline, now) + ) + ) + .returning() + + return nomination ?? null +} + +export const restoreApprovedNominationToSubmitted = async ( + nominationId: number +): Promise => { + const [nomination] = await getDb() + .update(nominations) + .set({ + status: "submitted", + completedAt: null, + updatedAt: now + }) + .where( + and( + eq(nominations.id, nominationId), + eq(nominations.status, "approved") + ) + ) + .returning() + + return nomination ?? null +} + +export const listExpiredSubmittedNominations = async ( + limit = 25 +): Promise => + getDb() + .select() + .from(nominations) + .where( + and( + eq(nominations.status, "submitted"), + lte(expiryDeadline, now) + ) + ) + .orderBy(asc(expiryDeadline), asc(nominations.id)) + .limit(limit) + +export const markNominationExpired = async ( + nominationId: number +): Promise => { + const [nomination] = await getDb() + .update(nominations) + .set({ + status: "expired", + completedAt: now, + updatedAt: now + }) + .where( + and( + eq(nominations.id, nominationId), + eq(nominations.status, "submitted"), + lte(expiryDeadline, now) + ) + ) + .returning() + + return nomination ?? null +} + +export const markExpiredSubmittedNominationForNominee = async ( + guildId: string, + nomineeId: string, + targetRoleId: string +): Promise => { + const [nomination] = await getDb() + .update(nominations) + .set({ + status: "expired", + completedAt: now, + updatedAt: now + }) + .where( + and( + eq(nominations.guildId, guildId), + eq(nominations.nomineeId, nomineeId), + eq(nominations.targetRoleId, targetRoleId), + eq(nominations.status, "submitted"), + lte(expiryDeadline, now) ) ) .returning() diff --git a/src/db/schema.ts b/src/db/schema.ts index 266aec4..b9cf27d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -229,9 +229,11 @@ export const nominations = sqliteTable( nomineeId: text("nominee_id").notNull(), nominatorId: text("nominator_id").notNull(), reason: text().notNull().default("No reason provided."), + messageId: text("message_id"), targetRoleId: text("target_role_id").notNull(), requiredApprovals: integer("required_approvals").notNull(), status: text().notNull().default("submitted"), + expiresAt: text("expires_at"), completedAt: text("completed_at"), createdAt: text("created_at") .notNull() diff --git a/src/index.ts b/src/index.ts index f2ec069..8e0f4d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { } from "./server/claimServer.js" import { handleFormsRequest } from "./forms/server.js" import { registerHelperLogsRoutes } from "./server/helperLogsServer.js" +import { runNominationExpiry } from "./services/nominationExpiry.js" import { runThreadLengthMonitor } from "./services/threadLengthMonitor.js" import { handleContentRightsApiRequest } from "./clawhubContentRights/api.js" @@ -127,9 +128,12 @@ export default { waitUntil: ctx.waitUntil.bind(ctx) }) }, - scheduled(_controller: ScheduledController, env: HermitEnv, ctx: ExecutionContext) { + scheduled(controller: ScheduledController, env: HermitEnv, ctx: ExecutionContext) { hydrateRuntimeEnv(env) - ctx.waitUntil(runThreadLengthMonitor(client)) + ctx.waitUntil(runNominationExpiry(client)) + if (!controller.cron || controller.cron === "0 */2 * * *") { + ctx.waitUntil(runThreadLengthMonitor(client)) + } } } satisfies ExportedHandler diff --git a/src/services/nominationExpiry.ts b/src/services/nominationExpiry.ts new file mode 100644 index 0000000..f82a8a2 --- /dev/null +++ b/src/services/nominationExpiry.ts @@ -0,0 +1,52 @@ +import { + type Client, + Routes, + serializePayload +} from "@buape/carbon" +import { buildNominationContainer } from "../components/nominationButtons.js" +import { + getNominationApproverIds, + listExpiredSubmittedNominations, + markNominationExpired +} from "../data/nominations.js" +import type { Nomination } from "../db/schema.js" + +export const editNominationMessageExpired = async ( + client: Client, + nomination: Nomination +) => { + if (!nomination.messageId) { + return + } + + const approverIds = await getNominationApproverIds(nomination.id) + await client.rest.patch( + Routes.channelMessage(nomination.channelId, nomination.messageId), + { + body: serializePayload({ + components: [buildNominationContainer(nomination, approverIds)], + allowedMentions: { parse: [] } + }) + } + ) +} + +export const runNominationExpiry = async (client: Client) => { + const expiredNominations = await listExpiredSubmittedNominations() + + for (const nomination of expiredNominations) { + const expiredNomination = await markNominationExpired(nomination.id) + if (!expiredNomination) { + continue + } + + try { + await editNominationMessageExpired(client, expiredNomination) + } catch (error) { + console.error( + `Failed to edit expired nomination message ${expiredNomination.id}:`, + error + ) + } + } +} diff --git a/tests/nominations.test.ts b/tests/nominations.test.ts index 6c1875f..2ea1bcc 100644 --- a/tests/nominations.test.ts +++ b/tests/nominations.test.ts @@ -4,10 +4,10 @@ import { readdirSync, readFileSync } from "node:fs" import { nominationConfig } from "../src/config/nominations.js" const nominationMigrationPaths = readdirSync("drizzle") - .filter((file) => /000[456]_.*\.sql/.test(file)) + .filter((file) => /000[4-7]_.*\.sql/.test(file)) .sort() -if (nominationMigrationPaths.length !== 3) { +if (nominationMigrationPaths.length !== 4) { throw new Error("Could not find nomination migrations") } @@ -29,16 +29,18 @@ const createNomination = (database: Database) => { nominee_id, nominator_id, reason, + expires_at, target_role_id, required_approvals, status - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ "guild-1", "channel-1", "nominee-1", "nominator-1", "excellent shell judgment", + "2099-01-01T00:00:00.000Z", "role-1", 3, "submitted" @@ -134,4 +136,110 @@ describe("nomination migration", () => { .get() as { count: number } expect(row.count).toBe(2) }) + + it("allows a new nomination after the previous nomination is expired", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths) { + applyMigration(database, `drizzle/${migrationPath}`) + } + const nominationId = createNomination(database) + database.run("update nominations set status = ? where id = ?", [ + "expired", + nominationId + ]) + + createNomination(database) + + const row = database + .query("select count(*) as count from nominations") + .get() as { count: number } + expect(row.count).toBe(2) + }) + + it("backfills expiry for nominations created before expiry columns existed", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths.slice(0, -1)) { + applyMigration(database, `drizzle/${migrationPath}`) + } + database.run( + `insert into nominations ( + guild_id, + channel_id, + nominee_id, + nominator_id, + reason, + target_role_id, + required_approvals, + status + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "guild-1", + "channel-1", + "nominee-1", + "nominator-1", + "excellent shell judgment", + "role-1", + 3, + "submitted" + ] + ) + + applyMigration(database, `drizzle/${nominationMigrationPaths.at(-1)}`) + + const row = database + .query("select created_at as createdAt, expires_at as expiresAt from nominations") + .get() as { createdAt: string; expiresAt: string } + expect(row.expiresAt).toBeString() + expect(row.expiresAt > row.createdAt).toBe(true) + }) + + it("expires submitted nominations left with a null expiry timestamp", () => { + const database = new Database(":memory:") + for (const migrationPath of nominationMigrationPaths) { + applyMigration(database, `drizzle/${migrationPath}`) + } + database.run( + `insert into nominations ( + guild_id, + channel_id, + nominee_id, + nominator_id, + reason, + expires_at, + target_role_id, + required_approvals, + status, + created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-49 hours'))`, + [ + "guild-1", + "channel-1", + "nominee-1", + "nominator-1", + "excellent shell judgment", + null, + "role-1", + 3, + "submitted" + ] + ) + const deadline = `coalesce(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ', created_at, '+${nominationConfig.expirationHours} hours'))` + + database.run( + `update nominations + set status = 'expired' + where guild_id = ? + and nominee_id = ? + and target_role_id = ? + and status = 'submitted' + and ${deadline} <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, + ["guild-1", "nominee-1", "role-1"] + ) + + const expired = database + .query("select status from nominations where expires_at is null") + .get() as { status: string } + expect(expired.status).toBe("expired") + createNomination(database) + }) }) diff --git a/wrangler.jsonc b/wrangler.jsonc index dfb35a2..17151c1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -50,6 +50,6 @@ } ], "triggers": { - "crons": ["0 */2 * * *"] + "crons": ["*/15 * * * *", "0 */2 * * *"] } } From d64c5e5f450bc23ead36b517b05abab9f7a60d5e Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:17:09 -0400 Subject: [PATCH 6/6] Post nominations as channel messages --- src/commands/nominate.ts | 8 ++------ src/components/nominationButtons.ts | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/commands/nominate.ts b/src/commands/nominate.ts index 9bc1d59..5446140 100644 --- a/src/commands/nominate.ts +++ b/src/commands/nominate.ts @@ -160,17 +160,13 @@ export default class NominateCommand extends BaseCommand { let postedMessage: APIMessage | null = null try { postedMessage = await interaction.client.rest.post( - Routes.webhook( - interaction.client.options.clientId, - interaction.rawData.token - ), + Routes.channelMessages(channelId), { body: serializePayload({ components: [buildNominationContainer(nomination, approverIds)], allowedMentions: { parse: [] } }) - }, - { wait: "true" } + } ) as APIMessage await setNominationMessageId(nomination.id, postedMessage.id) } catch { diff --git a/src/components/nominationButtons.ts b/src/components/nominationButtons.ts index cfd6553..3916413 100644 --- a/src/components/nominationButtons.ts +++ b/src/components/nominationButtons.ts @@ -305,8 +305,8 @@ export class NominationApproveButton extends Button { ephemeral: true, allowedMentions: { parse: [] } }) - } } +} export const nominationComponents = [ new NominationApproveButton()