diff --git a/.env.example b/.env.example index 928cf77..8f57111 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3b004e2..87e6e1b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 12e6c05..e8b4eee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 9c6f0cb..889ba28 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 0afe8da..9dc1d71 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker-compose.yml b/docker-compose.yml index 9245fe1..17179a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,3 @@ services: restart: always env_file: - .env - volumes: - - booster-role-data:/app/data - -volumes: - booster-role-data: diff --git a/drizzle.config.ts b/drizzle.config.ts index b0c062b..580b750 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -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" } }); diff --git a/package.json b/package.json index c50a47d..1e5dead 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config/index.ts b/src/config/index.ts index 8a5dcb0..1fedbb0 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,7 +11,7 @@ export function loadConfig(env: Record = 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 }; } diff --git a/src/db/index.ts b/src/db/index.ts index 902ce44..469b3ab 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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; diff --git a/src/db/migrations/0000_simple_titania.sql b/src/db/migrations/0000_simple_titania.sql new file mode 100644 index 0000000..eec45ab --- /dev/null +++ b/src/db/migrations/0000_simple_titania.sql @@ -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"); \ No newline at end of file diff --git a/src/db/migrations/meta/0000_snapshot.json b/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..789b788 --- /dev/null +++ b/src/db/migrations/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..e7809fc --- /dev/null +++ b/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1779350490110, + "tag": "0000_simple_titania", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index eeb8920..7d2265c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), diff --git a/src/db/sqlitePath.test.ts b/src/db/sqlitePath.test.ts deleted file mode 100644 index aeaf853..0000000 --- a/src/db/sqlitePath.test.ts +++ /dev/null @@ -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:"); - }); -}); diff --git a/src/db/sqlitePath.ts b/src/db/sqlitePath.ts deleted file mode 100644 index 4b7f5cd..0000000 --- a/src/db/sqlitePath.ts +++ /dev/null @@ -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; -} diff --git a/src/discord/bot.ts b/src/discord/bot.ts index 9c90dd5..0ff4fec 100644 --- a/src/discord/bot.ts +++ b/src/discord/bot.ts @@ -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; diff --git a/src/discord/commands/booster-role.ts b/src/discord/commands/booster-role.ts index 6058eea..6118b95 100644 --- a/src/discord/commands/booster-role.ts +++ b/src/discord/commands/booster-role.ts @@ -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)) + ); diff --git a/src/discord/interactionHandler.test.ts b/src/discord/interactionHandler.test.ts index a01baad..8cb483c 100644 --- a/src/discord/interactionHandler.test.ts +++ b/src/discord/interactionHandler.test.ts @@ -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 = {}) {} @@ -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 { - this.calls.push("delete"); + async deleteRole(input?: { userId: string }): Promise { + 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(); diff --git a/src/discord/interactionHandler.ts b/src/discord/interactionHandler.ts index 5cffcd2..ab2fc00 100644 --- a/src/discord/interactionHandler.ts +++ b/src/discord/interactionHandler.ts @@ -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; 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"); diff --git a/src/discord/registerCommands.test.ts b/src/discord/registerCommands.test.ts index f018652..efbe581 100644 --- a/src/discord/registerCommands.test.ts +++ b/src/discord/registerCommands.test.ts @@ -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" })]) + }) ]); }); }); diff --git a/src/services/bunSqliteBoosterRoleStore.test.ts b/src/services/bunSqliteBoosterRoleStore.test.ts deleted file mode 100644 index de8f253..0000000 --- a/src/services/bunSqliteBoosterRoleStore.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/services/bunSqliteBoosterRoleStore.ts b/src/services/bunSqliteBoosterRoleStore.ts deleted file mode 100644 index 6719c40..0000000 --- a/src/services/bunSqliteBoosterRoleStore.ts +++ /dev/null @@ -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 { - const row = this.db - .query(` - 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 { - this.db - .query(` - 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 { - this.db.query("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 - }; -} diff --git a/src/services/sqliteBoosterRoleStore.ts b/src/services/drizzleBoosterRoleStore.ts similarity index 78% rename from src/services/sqliteBoosterRoleStore.ts rename to src/services/drizzleBoosterRoleStore.ts index 28f16f1..5497cef 100644 --- a/src/services/sqliteBoosterRoleStore.ts +++ b/src/services/drizzleBoosterRoleStore.ts @@ -11,18 +11,14 @@ type DatabaseLike = { }; }; insert(table: typeof boosterRoles): { - values(record: BoosterRoleRecord): { - run(): unknown; - }; + values(record: BoosterRoleRecord): Promise | unknown; }; delete(table: typeof boosterRoles): { - where(condition: unknown): { - run(): unknown; - }; + where(condition: unknown): Promise | unknown; }; }; -export class SqliteBoosterRoleStore implements BoosterRoleStore { +export class DrizzleBoosterRoleStore implements BoosterRoleStore { constructor(private readonly db: DatabaseLike) {} async findByUser(guildId: string, userId: string): Promise { @@ -36,13 +32,12 @@ export class SqliteBoosterRoleStore implements BoosterRoleStore { } async create(record: BoosterRoleRecord): Promise { - await this.db.insert(boosterRoles).values(record).run(); + await this.db.insert(boosterRoles).values(record); } async delete(guildId: string, userId: string): Promise { 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))); } }