diff --git a/README.md b/README.md index b2cfb04..8767141 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,29 @@ bun run dev ## Scripts - `bun run dev` → `wrangler dev --env-file .env` -- `bun run deploy` → deploy worker +- `bun run deploy` → deploy worker locally with `.env` +- `bun run deploy:cf` → apply remote D1 migrations, then deploy Worker for Cloudflare Builds +- `bun run deploy:dry-run` → validate the Worker bundle without deploying - `bun run cf-typegen` → regenerate `worker-configuration.d.ts` - `bun run typecheck` → TypeScript check - `bun run db:generate` → generate Drizzle SQL - `bun run db:apply:local` / `db:apply:remote` → apply D1 migrations +## CI/CD + +Cloudflare Workers Builds deploys pushes to `main`. The deploy command should apply D1 migrations before deploying the Worker: + +```bash +bun run deploy:cf +``` + +Drizzle only generates SQL migrations. Wrangler applies them to D1: + +```bash +bun run db:generate +bun run db:apply:remote +``` + ## Gateway forwarder The main bot runs as a Cloudflare Worker. Gateway events are forwarded by the Bun app in `forwarder/`, usually running on Krill's machine. diff --git a/drizzle/0001_empty_millenium_guard.sql b/drizzle/0001_empty_millenium_guard.sql new file mode 100644 index 0000000..bc6489a --- /dev/null +++ b/drizzle/0001_empty_millenium_guard.sql @@ -0,0 +1,19 @@ +CREATE TABLE `claim_requests` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `guild_id` text NOT NULL, + `user_id` text NOT NULL, + `status` text DEFAULT 'submitted' NOT NULL, + `github_username` text, + `merged_pr_count` integer, + `review_message_id` text, + `review_thread_id` text, + `decided_at` text, + `decided_by_id` text, + `decision_reason` 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 UNIQUE INDEX `idx_claim_requests_guild_user` ON `claim_requests` (`guild_id`,`user_id`);--> statement-breakpoint +CREATE INDEX `idx_claim_requests_user_id` ON `claim_requests` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_claim_requests_status` ON `claim_requests` (`status`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..f17da38 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,443 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0863cd5b-48bd-4d21-8def-4b87379e85eb", + "prevId": "b004b5c2-29f6-4f87-9da9-0d5448cbd748", + "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": {} + }, + "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": {} + }, + "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 afef39f..8a3b9a7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775624708476, "tag": "0000_productive_tinkerer", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1778629335056, + "tag": "0001_empty_millenium_guard", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 9ab5b30..2b0d11c 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,11 @@ "scripts": { "dev": "wrangler dev --port 3000 --env-file .env", "deploy": "wrangler deploy --env-file .env", + "deploy:cf": "wrangler d1 migrations apply DB --remote && wrangler deploy", + "deploy:dry-run": "wrangler deploy --dry-run", "cf-typegen": "wrangler types", "postinstall": "wrangler types", + "test": "bun test", "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:apply:local": "wrangler d1 migrations apply hermit-db --local", diff --git a/src/commands/claim.ts b/src/commands/claim.ts index a42f865..8fd8751 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -7,9 +7,22 @@ import { Section, TextDisplay } from "@buape/carbon" +import { getClaimRequest } from "../data/claimRequests.js" import { createClaimUrl } from "../server/claimServer.js" import BaseCommand from "./base.js" +const existingClaimMessage = (status: string) => { + if (status === "accepted") { + return "Your clawtributor claim has already been accepted." + } + + if (status === "rejected") { + return "Your clawtributor claim has already been reviewed. Ask a moderator if you think this needs another look." + } + + return "Your clawtributor claim has already been submitted for review." +} + class ClaimLinkButton extends LinkButton { label = "Claim role" url: string @@ -42,6 +55,22 @@ export default class ClaimCommand extends BaseCommand { return } + const existingClaim = await getClaimRequest(userId, guildId) + if (existingClaim) { + await interaction.reply({ + components: [ + new Container( + [ + new TextDisplay("### Claim already exists"), + new TextDisplay(existingClaimMessage(existingClaim.status)) + ], + { accentColor: "#f1c40f" } + ) + ] + }) + return + } + const claimUrl = await createClaimUrl(userId, guildId) await interaction.reply({ diff --git a/src/data/claimRequests.ts b/src/data/claimRequests.ts new file mode 100644 index 0000000..ef68b75 --- /dev/null +++ b/src/data/claimRequests.ts @@ -0,0 +1,127 @@ +import { and, eq, sql } from "drizzle-orm" +import { getDb } from "../db.js" +import { claimRequests, type ClaimRequest } from "../db/schema.js" + +export type ClaimRequestStatus = + | "submitting" + | "submitted" + | "accepted" + | "rejected" + +type CreateClaimRequestInput = { + userId: string + guildId: string + githubUsername: string + mergedPrCount: number +} + +type ClaimRequestReviewInput = { + reviewMessageId: string + reviewThreadId?: string | null +} + +type ClaimRequestDecisionInput = { + userId: string + guildId: string + status: Extract + decidedById?: string | null + decisionReason?: string | null +} + +const now = sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` + +export const getClaimRequest = async ( + userId: string, + guildId: string +): Promise => { + const [claimRequest] = await getDb() + .select() + .from(claimRequests) + .where( + and(eq(claimRequests.userId, userId), eq(claimRequests.guildId, guildId)) + ) + .limit(1) + + return claimRequest ?? null +} + +export const createClaimRequest = async ({ + userId, + guildId, + githubUsername, + mergedPrCount +}: CreateClaimRequestInput): Promise< + | { created: true; claimRequest: ClaimRequest } + | { created: false; claimRequest: ClaimRequest | null } +> => { + const [claimRequest] = await getDb() + .insert(claimRequests) + .values({ + userId, + guildId, + status: "submitting", + githubUsername, + mergedPrCount + }) + .onConflictDoNothing({ + target: [claimRequests.guildId, claimRequests.userId] + }) + .returning() + + if (claimRequest) { + return { created: true, claimRequest } + } + + return { + created: false, + claimRequest: await getClaimRequest(userId, guildId) + } +} + +export const markClaimRequestSubmitted = async ( + id: number, + { reviewMessageId, reviewThreadId }: ClaimRequestReviewInput +) => { + await getDb() + .update(claimRequests) + .set({ + status: "submitted", + reviewMessageId, + reviewThreadId, + updatedAt: now + }) + .where(eq(claimRequests.id, id)) +} + +export const deleteClaimRequest = async (id: number) => { + await getDb().delete(claimRequests).where(eq(claimRequests.id, id)) +} + +export const recordClaimDecision = async ({ + userId, + guildId, + status, + decidedById, + decisionReason +}: ClaimRequestDecisionInput) => { + await getDb() + .insert(claimRequests) + .values({ + userId, + guildId, + status, + decidedAt: now, + decidedById: decidedById ?? null, + decisionReason: decisionReason ?? null + }) + .onConflictDoUpdate({ + target: [claimRequests.guildId, claimRequests.userId], + set: { + status, + decidedAt: now, + decidedById: decidedById ?? null, + decisionReason: decisionReason ?? null, + updatedAt: now + } + }) +} diff --git a/src/db/schema.ts b/src/db/schema.ts index fe38aea..56e495e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,11 @@ import { sql } from "drizzle-orm" -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { + index, + integer, + sqliteTable, + text, + uniqueIndex +} from "drizzle-orm/sqlite-core" export const keyValue = sqliteTable("keyValue", { key: text().primaryKey(), @@ -65,9 +71,39 @@ export const trackedThreads = sqliteTable( ] ) +export const claimRequests = sqliteTable( + "claim_requests", + { + id: integer().primaryKey({ autoIncrement: true }), + guildId: text("guild_id").notNull(), + userId: text("user_id").notNull(), + status: text().notNull().default("submitted"), + githubUsername: text("github_username"), + mergedPrCount: integer("merged_pr_count"), + reviewMessageId: text("review_message_id"), + reviewThreadId: text("review_thread_id"), + decidedAt: text("decided_at"), + decidedById: text("decided_by_id"), + decisionReason: text("decision_reason"), + 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) => [ + uniqueIndex("idx_claim_requests_guild_user").on(table.guildId, table.userId), + index("idx_claim_requests_user_id").on(table.userId), + index("idx_claim_requests_status").on(table.status) + ] +) + export type KeyValue = typeof keyValue.$inferSelect export type NewKeyValue = typeof keyValue.$inferInsert export type HelperEvent = typeof helperEvents.$inferSelect export type NewHelperEvent = typeof helperEvents.$inferInsert export type TrackedThread = typeof trackedThreads.$inferSelect export type NewTrackedThread = typeof trackedThreads.$inferInsert +export type ClaimRequest = typeof claimRequests.$inferSelect +export type NewClaimRequest = typeof claimRequests.$inferInsert diff --git a/src/server/claimServer.ts b/src/server/claimServer.ts index d7d56e8..83b7462 100644 --- a/src/server/claimServer.ts +++ b/src/server/claimServer.ts @@ -17,6 +17,13 @@ import { TextInput, TextInputStyle } from "@buape/carbon" +import { + createClaimRequest, + deleteClaimRequest, + getClaimRequest, + markClaimRequestSubmitted, + recordClaimDecision +} from "../data/claimRequests.js" const clawtributorsRoleId = "1458375944111915051" const claimReviewRoleId = "1460436814627078433" @@ -28,6 +35,18 @@ const discordApiBase = "https://discord.com/api/v10" const stateTtlMs = 10 * 60 * 1000 const reasonInputId = "claim-review-reason" +const existingClaimPageMessage = (status: string) => { + if (status === "accepted") { + return "Your clawtributor claim has already been accepted." + } + + if (status === "rejected") { + return "Your clawtributor claim has already been reviewed. Ask a moderator if you think this needs another look." + } + + return "Your clawtributor claim has already been submitted for review. You will receive a DM after it is accepted or rejected." +} + export const createClaimUrl = async (userId: string, guildId: string) => { const baseUrl = process.env.BASE_URL?.replace(/\/$/, "") const secret = process.env.DEPLOY_SECRET @@ -179,6 +198,15 @@ class ClaimReviewAcceptButton extends Button { return } + await recordClaimDecision({ + userId, + guildId, + status: "accepted", + decidedById: interaction.user?.id + }).catch((error) => { + console.error("Failed to record accepted claim:", error) + }) + const user = await interaction.client.fetchUser(userId).catch(() => null) await user?.send({ components: [ @@ -420,8 +448,12 @@ class ClaimReviewRejectModal extends Modal { typeof data.userId === "string" && data.userId.startsWith("s") ? data.userId.slice(1) : null + const guildId = + typeof data.guildId === "string" && data.guildId.startsWith("s") + ? data.guildId.slice(1) + : null const reason = interaction.fields.getText(reasonInputId)?.trim() - if (!userId) { + if (!userId || !guildId) { await interaction.reply({ components: [ new Container( @@ -437,6 +469,16 @@ class ClaimReviewRejectModal extends Modal { return } + await recordClaimDecision({ + userId, + guildId, + status: "rejected", + decidedById: interaction.user?.id, + decisionReason: reason + }).catch((error) => { + console.error("Failed to record rejected claim:", error) + }) + const user = await interaction.client.fetchUser(userId).catch(() => null) await user?.send({ components: [ @@ -612,6 +654,15 @@ const handleClaimCallback = async (request: Request, client: Client) => { return render("Claim link expired", "Run /claim in Discord again to get a fresh link.", 400) } + const existingClaim = await getClaimRequest(payload.userId, payload.guildId) + if (existingClaim) { + return render( + "Claim already exists", + existingClaimPageMessage(existingClaim.status), + 409 + ) + } + const tokenResponse = await fetch(`${discordApiBase}/oauth2/token`, { method: "POST", headers: { @@ -744,6 +795,20 @@ const handleClaimCallback = async (request: Request, client: Client) => { ) } + const claimRequestResult = await createClaimRequest({ + userId: payload.userId, + guildId: payload.guildId, + githubUsername: qualifyingSummary.username, + mergedPrCount: qualifyingSummary.totalCount + }) + if (!claimRequestResult.created) { + return render( + "Claim already exists", + existingClaimPageMessage(claimRequestResult.claimRequest?.status ?? "submitted"), + 409 + ) + } + const recentPullRequests = qualifyingSummary.recentPullRequests.length > 0 ? qualifyingSummary.recentPullRequests @@ -765,37 +830,70 @@ const handleClaimCallback = async (request: Request, client: Client) => { }) .join("\n") : "No recent merged pull requests found." - const message = await channel.send({ - components: [ - new Container( - [ - new TextDisplay(`<@&${claimReviewRoleId}>`), - new TextDisplay("### Clawtributor Claim Request"), - new TextDisplay( - `- User: <@${payload.userId}>\n- ID: ${payload.userId}\n- GitHub: [@${qualifyingSummary.username}]()\n- Merged PRs: **${qualifyingSummary.totalCount}**` - ), - new Separator({ divider: true, spacing: "small" }), - new TextDisplay("### 3 Most Recent Merged PRs"), - new TextDisplay(recentPullRequests), - new Separator({ divider: true, spacing: "small" }), - new Row([ - new ClaimReviewAcceptButton(payload.userId, payload.guildId), - new ClaimReviewRejectButton(payload.userId, payload.guildId) - ]) - ], - { accentColor: "#f1c40f" } - ) - ], - allowedMentions: { - roles: [claimReviewRoleId], - users: [] - } - }) - await message.startThread({ - name: `Clawtributor claim by ${qualifyingSummary.username}`.slice(0, 100), - auto_archive_duration: 1440 + const message = await channel + .send({ + components: [ + new Container( + [ + new TextDisplay(`<@&${claimReviewRoleId}>`), + new TextDisplay("### Clawtributor Claim Request"), + new TextDisplay( + `- User: <@${payload.userId}>\n- ID: ${payload.userId}\n- GitHub: [@${qualifyingSummary.username}]()\n- Merged PRs: **${qualifyingSummary.totalCount}**` + ), + new Separator({ divider: true, spacing: "small" }), + new TextDisplay("### 3 Most Recent Merged PRs"), + new TextDisplay(recentPullRequests), + new Separator({ divider: true, spacing: "small" }), + new Row([ + new ClaimReviewAcceptButton(payload.userId, payload.guildId), + new ClaimReviewRejectButton(payload.userId, payload.guildId) + ]) + ], + { accentColor: "#f1c40f" } + ) + ], + allowedMentions: { + roles: [claimReviewRoleId], + users: [] + } + }) + .catch(async (error) => { + console.error("Failed to send claim review request:", error) + await deleteClaimRequest(claimRequestResult.claimRequest.id).catch((error) => { + console.error("Failed to clean up unsent claim request:", error) + }) + return null + }) + if (!message) { + return render( + "Could not submit request", + "The bot could not send the claim review request. Ask a moderator to check the notification channel configuration.", + 500 + ) + } + + await markClaimRequestSubmitted(claimRequestResult.claimRequest.id, { + reviewMessageId: message.id + }).catch((error) => { + console.error("Failed to mark claim submitted:", error) }) + const thread = await message + .startThread({ + name: `Clawtributor claim by ${qualifyingSummary.username}`.slice(0, 100), + auto_archive_duration: 1440 + }) + .catch(() => null) + if (thread && typeof thread === "object" && "id" in thread) { + await markClaimRequestSubmitted(claimRequestResult.claimRequest.id, { + reviewMessageId: message.id, + reviewThreadId: + typeof thread.id === "string" ? thread.id : String(thread.id) + }).catch((error) => { + console.error("Failed to record claim review thread:", error) + }) + } + return render( "Claim submitted", "Your claim was sent for review. You will receive a DM after it is accepted or rejected." diff --git a/tests/claimRequests.test.ts b/tests/claimRequests.test.ts new file mode 100644 index 0000000..af4f1b7 --- /dev/null +++ b/tests/claimRequests.test.ts @@ -0,0 +1,59 @@ +import { Database } from "bun:sqlite" +import { describe, expect, it } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" + +const claimRequestsMigrationPath = readdirSync("drizzle") + .find((file) => file.startsWith("0001_") && file.endsWith(".sql")) + +if (!claimRequestsMigrationPath) { + throw new Error("Could not find claim requests migration") +} + +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) + } + } +} + +describe("claim request dedupe migration", () => { + it("allows only one claim per user per guild", () => { + const database = new Database(":memory:") + applyMigration(database, `drizzle/${claimRequestsMigrationPath}`) + + database.run( + "insert into claim_requests (guild_id, user_id, status, created_at, updated_at) values (?, ?, ?, ?, ?)", + ["guild-1", "user-1", "submitted", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z"] + ) + + expect(() => + database.run( + "insert into claim_requests (guild_id, user_id, status, created_at, updated_at) values (?, ?, ?, ?, ?)", + ["guild-1", "user-1", "submitted", "2026-05-12T00:01:00.000Z", "2026-05-12T00:01:00.000Z"] + ) + ).toThrow() + }) + + it("allows the same user to claim in a different guild", () => { + const database = new Database(":memory:") + applyMigration(database, `drizzle/${claimRequestsMigrationPath}`) + + database.run( + "insert into claim_requests (guild_id, user_id, status, created_at, updated_at) values (?, ?, ?, ?, ?)", + ["guild-1", "user-1", "submitted", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z"] + ) + database.run( + "insert into claim_requests (guild_id, user_id, status, created_at, updated_at) values (?, ?, ?, ?, ?)", + ["guild-2", "user-1", "submitted", "2026-05-12T00:01:00.000Z", "2026-05-12T00:01:00.000Z"] + ) + + const row = database + .query("select count(*) as count from claim_requests") + .get() as { count: number } + + expect(row.count).toBe(2) + }) +})