feat: migrate from SQLite to PostgreSQL and update database handling
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
DATABASE_URL=file:./data/booster-role.sqlite
|
||||
DATABASE_URL=postgresql://booster_role:booster_role@localhost:5432/booster_role
|
||||
BOOSTER_ROLE_ANCHOR_ROLE_ID=
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||
DATABASE_URL=${DATABASE_URL:-file:/app/data/booster-role.sqlite}
|
||||
DATABASE_URL=${DATABASE_URL:?DATABASE_URL is required}
|
||||
BOOSTER_ROLE_ANCHOR_ROLE_ID=${BOOSTER_ROLE_ANCHOR_ROLE_ID}
|
||||
EOF
|
||||
|
||||
@@ -81,11 +81,6 @@ jobs:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- booster-role-data:/app/data
|
||||
|
||||
volumes:
|
||||
booster-role-data:
|
||||
EOF
|
||||
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
|
||||
|
||||
@@ -9,6 +9,5 @@ COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_URL=file:/app/data/booster-role.sqlite
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
|
||||
@@ -6,7 +6,7 @@ Discord bot untuk memberi role custom kosmetik ke user yang sedang boost server.
|
||||
|
||||
- Bun + TypeScript
|
||||
- discord.js
|
||||
- SQLite via `bun:sqlite` + Drizzle schema
|
||||
- PostgreSQL via Drizzle ORM
|
||||
- Bun test runner
|
||||
|
||||
## Prasyarat
|
||||
@@ -30,7 +30,7 @@ Isi `.env`:
|
||||
DISCORD_TOKEN=token_bot_discord
|
||||
DISCORD_CLIENT_ID=client_id_aplikasi_discord
|
||||
DISCORD_GUILD_ID=id_server_discord
|
||||
DATABASE_URL=file:./data/booster-role.sqlite
|
||||
DATABASE_URL=postgresql://booster_role:booster_role@localhost:5432/booster_role
|
||||
BOOSTER_ROLE_ANCHOR_ROLE_ID=id_role_pembatas_opsional
|
||||
```
|
||||
|
||||
@@ -45,7 +45,7 @@ bun run db:generate
|
||||
bun run db:migrate
|
||||
```
|
||||
|
||||
SQLite default tersimpan di `./data/booster-role.sqlite`.
|
||||
Pastikan `DATABASE_URL` mengarah ke database PostgreSQL external sebelum menjalankan migration.
|
||||
|
||||
## Menjalankan bot
|
||||
|
||||
@@ -86,6 +86,7 @@ Command utama yang disiapkan:
|
||||
- `/booster-role recolor color` - ubah warna role milik sendiri.
|
||||
- `/booster-role icon image` - pasang atau ganti logo/icon role milik sendiri.
|
||||
- `/booster-role delete` - hapus role milik sendiri.
|
||||
- `/booster-role admin-delete user` - admin dengan permission `Administrator` bisa hapus role custom user lain. User target bisa claim lagi jika masih eligible.
|
||||
|
||||
## Catatan eligibility boost
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@discordjs/builders": "latest",
|
||||
"discord.js": "latest",
|
||||
"drizzle-orm": "latest",
|
||||
"postgres": "latest",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
@@ -175,6 +176,8 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
@@ -5,8 +5,3 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- booster-role-data:/app/data
|
||||
|
||||
volumes:
|
||||
booster-role-data:
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ import { defineConfig } from "drizzle-kit";
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./src/db/migrations",
|
||||
dialect: "sqlite",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "file:./data/booster-role.sqlite"
|
||||
url: process.env.DATABASE_URL ?? "postgresql://booster_role:booster_role@localhost:5432/booster_role"
|
||||
}
|
||||
});
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "latest",
|
||||
"discord.js": "latest",
|
||||
"drizzle-orm": "latest"
|
||||
"drizzle-orm": "latest",
|
||||
"postgres": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
||||
discordToken: requireEnv(env, "DISCORD_TOKEN"),
|
||||
discordClientId: requireEnv(env, "DISCORD_CLIENT_ID"),
|
||||
discordGuildId: requireEnv(env, "DISCORD_GUILD_ID"),
|
||||
databaseUrl: env.DATABASE_URL ?? "file:./data/booster-role.sqlite",
|
||||
databaseUrl: env.DATABASE_URL ?? "postgresql://booster_role:booster_role@localhost:5432/booster_role",
|
||||
boosterRoleAnchorRoleId: env.BOOSTER_ROLE_ANCHOR_ROLE_ID ?? null
|
||||
};
|
||||
}
|
||||
|
||||
+6
-5
@@ -1,9 +1,10 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export function createDb(databaseUrl: string) {
|
||||
const sqlitePath = databaseUrl.replace(/^file:/, "");
|
||||
const sqlite = new Database(sqlitePath);
|
||||
return drizzle(sqlite, { schema });
|
||||
const client = postgres(databaseUrl);
|
||||
return drizzle(client, { schema });
|
||||
}
|
||||
|
||||
export type AppDb = ReturnType<typeof createDb>;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "booster_roles" (
|
||||
"guild_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"created_at" integer NOT NULL,
|
||||
"updated_at" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "booster_roles_guild_user_idx" ON "booster_roles" USING btree ("guild_id","user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "booster_roles_guild_role_idx" ON "booster_roles" USING btree ("guild_id","role_id");
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"id": "e654a614-04fe-4432-aee1-659bc4a0475e",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.booster_roles": {
|
||||
"name": "booster_roles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role_id": {
|
||||
"name": "role_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"booster_roles_guild_user_idx": {
|
||||
"name": "booster_roles_guild_user_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "guild_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"booster_roles_guild_role_idx": {
|
||||
"name": "booster_roles_guild_role_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "guild_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "role_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1779350490110,
|
||||
"tag": "0000_simple_titania",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { integer, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
export const boosterRoles = sqliteTable(
|
||||
export const boosterRoles = pgTable(
|
||||
"booster_roles",
|
||||
{
|
||||
guildId: text("guild_id").notNull(),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { prepareSqlitePath } from "./sqlitePath";
|
||||
|
||||
describe("prepareSqlitePath", () => {
|
||||
test("creates parent directory for file database urls", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "booster-role-db-"));
|
||||
const dbPath = join(root, "nested", "booster-role.sqlite");
|
||||
|
||||
expect(existsSync(join(root, "nested"))).toBe(false);
|
||||
expect(prepareSqlitePath(`file:${dbPath}`)).toBe(dbPath);
|
||||
expect(existsSync(join(root, "nested"))).toBe(true);
|
||||
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("does not create a directory for in-memory databases", () => {
|
||||
expect(prepareSqlitePath(":memory:")).toBe(":memory:");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export function prepareSqlitePath(databaseUrl: string): string {
|
||||
const sqlitePath = databaseUrl.replace(/^file:/, "");
|
||||
|
||||
if (sqlitePath !== ":memory:") {
|
||||
mkdirSync(dirname(sqlitePath), { recursive: true });
|
||||
}
|
||||
|
||||
return sqlitePath;
|
||||
}
|
||||
+4
-5
@@ -1,16 +1,15 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Client } from "discord.js";
|
||||
import type { AppConfig } from "../config";
|
||||
import { prepareSqlitePath } from "../db/sqlitePath";
|
||||
import { createDb } from "../db";
|
||||
import { BoosterRoleService } from "../services/boosterRoleService";
|
||||
import { BunSqliteBoosterRoleStore } from "../services/bunSqliteBoosterRoleStore";
|
||||
import { DrizzleBoosterRoleStore } from "../services/drizzleBoosterRoleStore";
|
||||
import { DiscordRoleRepository } from "../services/discordRoleRepository";
|
||||
import { handleGuildMemberUpdate } from "./events/guildMemberUpdate";
|
||||
import { handleInteraction } from "./interactionHandler";
|
||||
|
||||
export function attachBotHandlers(client: Client, config: AppConfig): void {
|
||||
const db = new Database(prepareSqlitePath(config.databaseUrl));
|
||||
const store = new BunSqliteBoosterRoleStore(db);
|
||||
const db = createDb(config.databaseUrl);
|
||||
const store = new DrizzleBoosterRoleStore(db);
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||
|
||||
@@ -29,4 +29,10 @@ export const boosterRoleCommand = new SlashCommandBuilder()
|
||||
.setDescription("Set an optional icon on your bot-managed booster role")
|
||||
.addAttachmentOption((option) => option.setName("image").setDescription("Role icon image").setRequired(true))
|
||||
)
|
||||
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"));
|
||||
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"))
|
||||
.addSubcommand((command) =>
|
||||
command
|
||||
.setName("admin-delete")
|
||||
.setDescription("Delete another user's bot-managed booster role")
|
||||
.addUserOption((option) => option.setName("user").setDescription("User whose booster role should be deleted").setRequired(true))
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import { MessageFlags, PermissionFlagsBits } from "discord.js";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { handleInteraction, type BoosterRoleCommandService, type ChatInputInteractionLike } from "./interactionHandler";
|
||||
|
||||
@@ -8,6 +8,7 @@ class FakeInteraction implements ChatInputInteractionLike {
|
||||
commandName = "booster-role";
|
||||
user = { id: "user" };
|
||||
guildId: string | null = "guild";
|
||||
memberPermissions: { has(permission: bigint): boolean } | null = null;
|
||||
replies: Reply[] = [];
|
||||
|
||||
constructor(private readonly subcommand: string, private readonly values: Record<string, unknown> = {}) {}
|
||||
@@ -23,6 +24,7 @@ class FakeInteraction implements ChatInputInteractionLike {
|
||||
options = {
|
||||
getSubcommand: () => this.subcommand,
|
||||
getString: (name: string) => (this.values[name] as string | null) ?? null,
|
||||
getUser: (name: string) => (this.values[name] as { id: string } | null) ?? null,
|
||||
getAttachment: (name: string) => (this.values[name] as { contentType: string | null; size: number; url: string } | null) ?? null
|
||||
};
|
||||
}
|
||||
@@ -47,8 +49,8 @@ class FakeService implements BoosterRoleCommandService {
|
||||
this.calls.push("icon");
|
||||
}
|
||||
|
||||
async deleteRole(): Promise<void> {
|
||||
this.calls.push("delete");
|
||||
async deleteRole(input?: { userId: string }): Promise<void> {
|
||||
this.calls.push(input?.userId ? `delete:${input.userId}` : "delete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ describe("handleInteraction", () => {
|
||||
});
|
||||
|
||||
test("routes update subcommands and replies", async () => {
|
||||
for (const subcommand of ["rename", "recolor", "icon", "delete"]) {
|
||||
for (const subcommand of ["rename", "recolor", "icon"]) {
|
||||
const interaction = new FakeInteraction(subcommand, { name: "Test", color: "#AABBCC", image: { contentType: "image/png", size: 100, url: "https://cdn.discordapp.com/icon.png" } });
|
||||
const service = new FakeService();
|
||||
|
||||
@@ -75,6 +77,38 @@ describe("handleInteraction", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes own delete command", async () => {
|
||||
const interaction = new FakeInteraction("delete");
|
||||
const service = new FakeService();
|
||||
|
||||
await handleInteraction(interaction, service, { isBoosting: async () => true });
|
||||
|
||||
expect(service.calls).toEqual(["delete:user"]);
|
||||
expect(interaction.replies[0]).toEqual({ content: "Booster role deleted.", flags: MessageFlags.Ephemeral });
|
||||
});
|
||||
|
||||
test("routes admin delete command for target user", async () => {
|
||||
const interaction = new FakeInteraction("admin-delete", { user: { id: "target-user" } });
|
||||
interaction.memberPermissions = { has: (permission) => permission === PermissionFlagsBits.Administrator };
|
||||
const service = new FakeService();
|
||||
|
||||
await handleInteraction(interaction, service, { isBoosting: async () => true });
|
||||
|
||||
expect(service.calls).toEqual(["delete:target-user"]);
|
||||
expect(interaction.replies[0]).toEqual({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
|
||||
});
|
||||
|
||||
test("rejects admin delete without Administrator permission", async () => {
|
||||
const interaction = new FakeInteraction("admin-delete", { user: { id: "target-user" } });
|
||||
interaction.memberPermissions = { has: () => false };
|
||||
const service = new FakeService();
|
||||
|
||||
await handleInteraction(interaction, service, { isBoosting: async () => true });
|
||||
|
||||
expect(service.calls).toEqual([]);
|
||||
expect(interaction.replies[0]).toEqual({ content: "Administrator permission is required", flags: MessageFlags.Ephemeral });
|
||||
});
|
||||
|
||||
test("turns service errors into private replies", async () => {
|
||||
const interaction = new FakeInteraction("claim", { name: "VIP" });
|
||||
const service = new FakeService();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import { MessageFlags, PermissionFlagsBits } from "discord.js";
|
||||
import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService";
|
||||
|
||||
export type ChatInputInteractionLike = {
|
||||
commandName: string;
|
||||
guildId: string | null;
|
||||
user: { id: string };
|
||||
memberPermissions: { has(permission: bigint): boolean } | null;
|
||||
isChatInputCommand(): boolean;
|
||||
reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise<unknown>;
|
||||
options: {
|
||||
getSubcommand(): string;
|
||||
getString(name: string): string | null;
|
||||
getUser(name: string): { id: string } | null;
|
||||
getAttachment(name: string): { contentType: string | null; size: number; url: string } | null;
|
||||
};
|
||||
};
|
||||
@@ -75,6 +77,13 @@ export async function handleInteraction(
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "admin-delete") {
|
||||
requireAdministrator(interaction);
|
||||
await service.deleteRole({ guildId, userId: requireUser(interaction, "user").id });
|
||||
await interaction.reply({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Unknown booster-role subcommand");
|
||||
} catch (error) {
|
||||
await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", flags: MessageFlags.Ephemeral });
|
||||
@@ -92,6 +101,18 @@ function requireString(interaction: ChatInputInteractionLike, name: string): str
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireUser(interaction: ChatInputInteractionLike, name: string): { id: string } {
|
||||
const value = interaction.options.getUser(name);
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireAdministrator(interaction: ChatInputInteractionLike): void {
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
throw new Error("Administrator permission is required");
|
||||
}
|
||||
}
|
||||
|
||||
function requireIcon(interaction: ChatInputInteractionLike, name: string): RoleIcon {
|
||||
const icon = optionalIcon(interaction, name);
|
||||
if (!icon) throw new Error("Role icon image is required");
|
||||
|
||||
@@ -20,7 +20,11 @@ describe("registerGuildCommands", () => {
|
||||
|
||||
expect(rest.route).toBe("/applications/client/guilds/guild/commands");
|
||||
expect(rest.body).toEqual([
|
||||
expect.objectContaining({ name: "booster-role", description: "Manage your cosmetic booster role" })
|
||||
expect.objectContaining({
|
||||
name: "booster-role",
|
||||
description: "Manage your cosmetic booster role",
|
||||
options: expect.arrayContaining([expect.objectContaining({ name: "admin-delete" })])
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BunSqliteBoosterRoleStore } from "./bunSqliteBoosterRoleStore";
|
||||
|
||||
describe("BunSqliteBoosterRoleStore", () => {
|
||||
test("creates schema and stores booster role records", async () => {
|
||||
const db = new Database(":memory:");
|
||||
const store = new BunSqliteBoosterRoleStore(db);
|
||||
const record = {
|
||||
guildId: "guild",
|
||||
userId: "user",
|
||||
roleId: "role",
|
||||
name: "Test Role",
|
||||
color: "#AABBCC",
|
||||
icon: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
|
||||
await store.create(record);
|
||||
|
||||
expect(await store.findByUser("guild", "user")).toEqual(record);
|
||||
await store.delete("guild", "user");
|
||||
expect(await store.findByUser("guild", "user")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService";
|
||||
|
||||
export class BunSqliteBoosterRoleStore implements BoosterRoleStore {
|
||||
constructor(private readonly db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS booster_roles (
|
||||
guild_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(guild_id, user_id),
|
||||
UNIQUE(guild_id, role_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
|
||||
const row = this.db
|
||||
.query<BoosterRoleRow, [string, string]>(`
|
||||
SELECT guild_id, user_id, role_id, name, color, icon, created_at, updated_at
|
||||
FROM booster_roles
|
||||
WHERE guild_id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
`)
|
||||
.get(guildId, userId);
|
||||
|
||||
return row ? toRecord(row) : null;
|
||||
}
|
||||
|
||||
async create(record: BoosterRoleRecord): Promise<void> {
|
||||
this.db
|
||||
.query<unknown, [string, string, string, string, string | null, string | null, number, number]>(`
|
||||
INSERT INTO booster_roles (guild_id, user_id, role_id, name, color, icon, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(record.guildId, record.userId, record.roleId, record.name, record.color, record.icon, record.createdAt, record.updatedAt);
|
||||
}
|
||||
|
||||
async delete(guildId: string, userId: string): Promise<void> {
|
||||
this.db.query<unknown, [string, string]>("DELETE FROM booster_roles WHERE guild_id = ? AND user_id = ?").run(guildId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
type BoosterRoleRow = {
|
||||
guild_id: string;
|
||||
user_id: string;
|
||||
role_id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
function toRecord(row: BoosterRoleRow): BoosterRoleRecord {
|
||||
return {
|
||||
guildId: row.guild_id,
|
||||
userId: row.user_id,
|
||||
roleId: row.role_id,
|
||||
name: row.name,
|
||||
color: row.color,
|
||||
icon: row.icon,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
@@ -11,18 +11,14 @@ type DatabaseLike = {
|
||||
};
|
||||
};
|
||||
insert(table: typeof boosterRoles): {
|
||||
values(record: BoosterRoleRecord): {
|
||||
run(): unknown;
|
||||
};
|
||||
values(record: BoosterRoleRecord): Promise<unknown> | unknown;
|
||||
};
|
||||
delete(table: typeof boosterRoles): {
|
||||
where(condition: unknown): {
|
||||
run(): unknown;
|
||||
};
|
||||
where(condition: unknown): Promise<unknown> | unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export class SqliteBoosterRoleStore implements BoosterRoleStore {
|
||||
export class DrizzleBoosterRoleStore implements BoosterRoleStore {
|
||||
constructor(private readonly db: DatabaseLike) {}
|
||||
|
||||
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
|
||||
@@ -36,13 +32,12 @@ export class SqliteBoosterRoleStore implements BoosterRoleStore {
|
||||
}
|
||||
|
||||
async create(record: BoosterRoleRecord): Promise<void> {
|
||||
await this.db.insert(boosterRoles).values(record).run();
|
||||
await this.db.insert(boosterRoles).values(record);
|
||||
}
|
||||
|
||||
async delete(guildId: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(boosterRoles)
|
||||
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)))
|
||||
.run();
|
||||
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user