Skip to content

Commit abafb75

Browse files
committed
Added database integration
1 parent db0e615 commit abafb75

28 files changed

Lines changed: 6950 additions & 464 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/node_modules
22
/dist
3-
.env
3+
.env
4+
/generated/prisma

package-lock.json

Lines changed: 401 additions & 161 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,20 @@
1313
"author": "",
1414
"license": "ISC",
1515
"devDependencies": {
16-
"@types/node": "^20.0.0",
16+
"@types/node": "^20.19.39",
17+
"@types/pg": "^8.20.0",
18+
"prisma": "^7.8.0",
1719
"ts-node": "^10.9.0",
1820
"typescript": "^5.0.0"
1921
},
2022
"dependencies": {
2123
"@clack/prompts": "^1.1.0",
24+
"@prisma/adapter-pg": "^7.8.0",
25+
"@prisma/client": "^7.8.0",
2226
"chalk": "^4.1.2",
2327
"discord.js": "^14.25.1",
24-
"dotenv": "^17.4.0",
25-
"prisma": "^7.6.0",
28+
"dotenv": "^17.4.2",
29+
"pg": "^8.20.0",
2630
"tsx": "^4.21.0",
2731
"zod": "^4.3.6"
2832
}

prisma.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import "dotenv/config";
2+
import { defineConfig, env } from "prisma/config";
3+
4+
export default defineConfig({
5+
schema: "prisma/schema.prisma",
6+
migrations: {
7+
path: "prisma/migrations",
8+
},
9+
datasource: {
10+
url: env("DATABASE_URL"),
11+
},
12+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
-- CreateTable
2+
CREATE TABLE "User" (
3+
"id" TEXT NOT NULL,
4+
"discordId" TEXT NOT NULL,
5+
"username" TEXT NOT NULL,
6+
"discriminator" TEXT,
7+
"avatarUrl" TEXT,
8+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"updatedAt" TIMESTAMP(3) NOT NULL,
10+
11+
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "Guild" (
16+
"id" TEXT NOT NULL,
17+
"discordId" TEXT NOT NULL,
18+
"name" TEXT NOT NULL,
19+
"iconUrl" TEXT,
20+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21+
"updatedAt" TIMESTAMP(3) NOT NULL,
22+
23+
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
24+
);
25+
26+
-- CreateTable
27+
CREATE TABLE "Booster" (
28+
"id" TEXT NOT NULL,
29+
"userId" TEXT NOT NULL,
30+
"guildId" TEXT NOT NULL,
31+
"boostedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
32+
"expiresAt" TIMESTAMP(3),
33+
"active" BOOLEAN NOT NULL DEFAULT true,
34+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
35+
"updatedAt" TIMESTAMP(3) NOT NULL,
36+
37+
CONSTRAINT "Booster_pkey" PRIMARY KEY ("id")
38+
);
39+
40+
-- CreateIndex
41+
CREATE UNIQUE INDEX "User_discordId_key" ON "User"("discordId");
42+
43+
-- CreateIndex
44+
CREATE UNIQUE INDEX "Guild_discordId_key" ON "Guild"("discordId");
45+
46+
-- CreateIndex
47+
CREATE INDEX "Booster_guildId_idx" ON "Booster"("guildId");
48+
49+
-- CreateIndex
50+
CREATE INDEX "Booster_userId_idx" ON "Booster"("userId");
51+
52+
-- CreateIndex
53+
CREATE UNIQUE INDEX "Booster_userId_guildId_key" ON "Booster"("userId", "guildId");
54+
55+
-- AddForeignKey
56+
ALTER TABLE "Booster" ADD CONSTRAINT "Booster_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
57+
58+
-- AddForeignKey
59+
ALTER TABLE "Booster" ADD CONSTRAINT "Booster_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost.
5+
6+
*/
7+
-- DropForeignKey
8+
ALTER TABLE "Booster" DROP CONSTRAINT "Booster_userId_fkey";
9+
10+
-- DropTable
11+
DROP TABLE "User";
12+
13+
-- CreateTable
14+
CREATE TABLE "CustomRole" (
15+
"id" TEXT NOT NULL,
16+
"boosterId" TEXT NOT NULL,
17+
"discordRoleId" TEXT NOT NULL,
18+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
"updatedAt" TIMESTAMP(3) NOT NULL,
20+
21+
CONSTRAINT "CustomRole_pkey" PRIMARY KEY ("id")
22+
);
23+
24+
-- CreateIndex
25+
CREATE UNIQUE INDEX "CustomRole_boosterId_key" ON "CustomRole"("boosterId");
26+
27+
-- CreateIndex
28+
CREATE UNIQUE INDEX "CustomRole_discordRoleId_key" ON "CustomRole"("discordRoleId");
29+
30+
-- AddForeignKey
31+
ALTER TABLE "CustomRole" ADD CONSTRAINT "CustomRole_boosterId_fkey" FOREIGN KEY ("boosterId") REFERENCES "Booster"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Please do not edit this file manually
2+
# It should be added in your version-control system (e.g., Git)
3+
provider = "postgresql"

prisma/schema.prisma

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// This is your Prisma schema file,
2+
// learn more about it in the docs: https://pris.ly/d/prisma-schema
3+
4+
// Get a free hosted Postgres database in seconds: `npx create-db`
5+
6+
generator client {
7+
provider = "prisma-client"
8+
output = "../src/generated/prisma"
9+
}
10+
11+
datasource db {
12+
provider = "postgresql"
13+
}
14+
15+
model Guild {
16+
id String @id @default(cuid())
17+
discordId String @unique
18+
name String
19+
iconUrl String?
20+
createdAt DateTime @default(now())
21+
updatedAt DateTime @updatedAt
22+
23+
boosters Booster[]
24+
}
25+
26+
model Booster {
27+
id String @id @default(cuid())
28+
userId String
29+
guildId String
30+
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
31+
boostedAt DateTime @default(now())
32+
boostCounts Int @default(1)
33+
expiresAt DateTime?
34+
active Boolean @default(true)
35+
customRole CustomRole?
36+
createdAt DateTime @default(now())
37+
updatedAt DateTime @updatedAt
38+
39+
@@unique([userId, guildId])
40+
@@index([guildId])
41+
@@index([userId])
42+
}
43+
44+
model CustomRole {
45+
id String @id @default(cuid())
46+
boosterId String @unique
47+
booster Booster @relation(fields: [boosterId], references: [id], onDelete: Cascade)
48+
discordRoleId String @unique
49+
createdAt DateTime @default(now())
50+
updatedAt DateTime @updatedAt
51+
}

src/commands/booster.ts

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getActiveBoosters,
1414
getTotalBoosts,
1515
registerBoost,
16+
ensureGuild,
1617
} from "../services/boosterService.js";
1718

1819
const boosterCommand: Command = {
@@ -56,7 +57,7 @@ const boosterCommand: Command = {
5657
.setDescription("Amount to remove")
5758
.setRequired(true)
5859
.setMinValue(1)
59-
)
60+
)
6061
)
6162
.addSubcommand((sub) =>
6263
sub.setName("stats").setDescription("View server boost statistics")
@@ -65,53 +66,60 @@ const boosterCommand: Command = {
6566
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
6667
const sub = interaction.options.getSubcommand();
6768

69+
// All subcommands need a guild — bail early if missing
70+
const discordGuild = interaction.guild;
71+
if (!discordGuild) {
72+
await interaction.reply({ content: "This command can only be used in a server.", ephemeral: true });
73+
return;
74+
}
75+
76+
await interaction.deferReply({ ephemeral: true });
77+
6878
if (sub === "check") {
6979
const user = interaction.options.getUser("user", true);
70-
const record = getBooster(user.id);
80+
const result = await getBooster(user.id, interaction);
7181

72-
if (!record) {
73-
await interaction.reply({
74-
content: `No booster record found for ${user.tag}.`,
75-
ephemeral: true,
76-
});
82+
if (!result?.success || !result.data) {
83+
await interaction.editReply({ content: `No booster record found for ${user.tag}.` });
7784
return;
7885
}
7986

87+
const booster = result.data;
88+
8089
const embed = new EmbedBuilder()
81-
.setColor(record.boosting ? 0xf47fff : 0x99aab5)
82-
.setTitle(`Booster Info: ${record.username}`)
90+
.setColor(booster.active ? 0xf47fff : 0x99aab5)
91+
.setTitle(`Booster Info: ${user.username}`)
8392
.addFields(
84-
{ name: "Status", value: record.boosting ? "Active" : "Inactive", inline: true },
85-
{ name: "Boost Count", value: String(record.boostCount), inline: true },
86-
{ name: "Custom Role", value: record.customRoleId ? `<@&${record.customRoleId}>` : "None", inline: true },
87-
{ name: "Private Channel", value: record.privateChannelId ? `<#${record.privateChannelId}>` : "None", inline: true },
88-
{ name: "First Boosted", value: new Date(record.firstBoostAt).toLocaleDateString(), inline: true },
89-
{ name: "Last Updated", value: new Date(record.lastUpdatedAt).toLocaleDateString(), inline: true }
93+
{ name: "Status", value: booster.active ? "🟢 Active" : "🔴 Inactive", inline: true },
94+
{ name: "Boost Count", value: String(booster.boostCounts ?? 0), inline: true },
95+
{
96+
name: "Custom Role",
97+
value: booster.customRole ? `<@&${booster.customRole.discordRoleId}>` : "None",
98+
inline: true,
99+
},
100+
{ name: "First Boosted", value: `<t:${Math.floor(booster.boostedAt.getTime() / 1000)}:D>`, inline: true },
101+
{ name: "Last Updated", value: `<t:${Math.floor(booster.updatedAt.getTime() / 1000)}:R>`, inline: true }
90102
)
91103
.setTimestamp();
92104

93-
await interaction.reply({ embeds: [embed], ephemeral: true });
105+
await interaction.editReply({ embeds: [embed] });
94106
return;
95107
}
96108

97109
if (sub === "add") {
98110
const user = interaction.options.getUser("user", true);
99111
const amount = interaction.options.getInteger("amount", true);
100112

101-
let record = getBooster(user.id);
102-
if (!record) {
103-
record = registerBoost(user.id, user.username);
104-
}
113+
await registerBoost(user.id, discordGuild.id, discordGuild.name, discordGuild.iconURL());
105114

106-
const updated = addBoostCount(user.id, amount);
115+
const updated = await addBoostCount(user.id, discordGuild.id, amount);
107116
if (!updated) {
108-
await interaction.reply({ content: "Failed to update boost count.", ephemeral: true });
117+
await interaction.editReply({ content: "Failed to update boost count." });
109118
return;
110119
}
111120

112-
await interaction.reply({
113-
content: `Added ${amount} boost(s) to ${user.tag}. New count: ${updated.boostCount}.`,
114-
ephemeral: true,
121+
await interaction.editReply({
122+
content: `Added **${amount}** boost(s) to ${user.tag}. New count: **${updated.boostCounts}**.`,
115123
});
116124
return;
117125
}
@@ -120,23 +128,24 @@ const boosterCommand: Command = {
120128
const user = interaction.options.getUser("user", true);
121129
const amount = interaction.options.getInteger("amount", true);
122130

123-
const updated = removeBoostCount(user.id, amount);
131+
const updated = await removeBoostCount(user.id, discordGuild.id, amount);
124132
if (!updated) {
125-
await interaction.reply({ content: `No booster record found for ${user.tag}.`, ephemeral: true });
133+
await interaction.editReply({ content: `No booster record found for ${user.tag}.` });
126134
return;
127135
}
128136

129-
await interaction.reply({
130-
content: `Removed ${amount} boost(s) from ${user.tag}. New count: ${updated.boostCount}.`,
131-
ephemeral: true,
137+
await interaction.editReply({
138+
content: `Removed **${amount}** boost(s) from ${user.tag}. New count: **${updated.boostCounts}**.`,
132139
});
133140
return;
134141
}
135142

136143
if (sub === "stats") {
137-
const activeBoosters = getActiveBoosters();
138-
const allBoosters = getAllBoosters();
139-
const totalBoosts = getTotalBoosts();
144+
const [activeBoosters, allBoosters, totalBoosts] = await Promise.all([
145+
getActiveBoosters(discordGuild.id),
146+
getAllBoosters(discordGuild.id),
147+
getTotalBoosts(discordGuild.id),
148+
]);
140149

141150
const embed = new EmbedBuilder()
142151
.setColor(0xf47fff)
@@ -148,7 +157,7 @@ const boosterCommand: Command = {
148157
)
149158
.setTimestamp();
150159

151-
await interaction.reply({ embeds: [embed] });
160+
await interaction.editReply({ embeds: [embed] });
152161
return;
153162
}
154163
},

0 commit comments

Comments
 (0)