From 83b292dcd5abfe6cab1aa5c1857a412128486120 Mon Sep 17 00:00:00 2001
From: Gabriel Tavares <bieltmd@outlook.com>
Date: Sat, 8 Jun 2024 13:40:55 -0400
Subject: [PATCH 1/4] feat: announce when user levels up

---
 src/events/giveXp.ts           | 7 ++++---
 src/events/supressUrlEmbeds.ts | 2 +-
 src/structures/xpHandler.ts    | 7 +++++--
 src/utils/dateFormat.ts        | 6 +++---
 4 files changed, 13 insertions(+), 9 deletions(-)

diff --git a/src/events/giveXp.ts b/src/events/giveXp.ts
index 34c980b..5ecc098 100644
--- a/src/events/giveXp.ts
+++ b/src/events/giveXp.ts
@@ -1,11 +1,12 @@
 import { container } from "@sapphire/pieces";
-import type { Message } from "discord.js";
+import type { Message, TextChannel } from "discord.js";
 
 export default function giveXp(message: Message) {
 	const member = message.member;
 	const guild = message.guild;
+	const channel = message.channel as TextChannel;
 
-	if (!member || !guild) return;
+	if (!member || !guild || !channel) return;
 
-	container.expHandler.addExp(member, guild);
+	container.expHandler.addExp(member, guild, channel);
 }
diff --git a/src/events/supressUrlEmbeds.ts b/src/events/supressUrlEmbeds.ts
index 95e8ec6..2ea2657 100644
--- a/src/events/supressUrlEmbeds.ts
+++ b/src/events/supressUrlEmbeds.ts
@@ -4,7 +4,7 @@ import type { Message } from "discord.js";
 export default async function suppressUrlEmbeds(message: Message) {
 	const content = message.content;
 
-	if (!message.embeds.length) return;
+	if (message.embeds.length <= 0) return;
 
 	for (const url in urlFixers) {
 		if (content.includes(url)) {
diff --git a/src/structures/xpHandler.ts b/src/structures/xpHandler.ts
index 3f9991e..eb9c6c6 100644
--- a/src/structures/xpHandler.ts
+++ b/src/structures/xpHandler.ts
@@ -1,6 +1,6 @@
 import { ExpValues } from "@/utils/contants.js";
 import { container } from "@sapphire/pieces";
-import type { Guild, GuildMember } from "discord.js";
+import type { Guild, GuildMember, TextChannel } from "discord.js";
 
 export type ExpStats = {
 	exp: number;
@@ -53,7 +53,7 @@ export default class ExpHandler {
 		};
 	}
 
-	public async addExp(member: GuildMember, guild: Guild) {
+	public async addExp(member: GuildMember, guild: Guild, channel: TextChannel) {
 		const expStats = await this.getStats(member, guild);
 
 		if (!expStats?.canGetExp) return;
@@ -67,6 +67,9 @@ export default class ExpHandler {
 		if (expStats.exp >= xpRequired) {
 			expStats.level++;
 			expStats.exp = Math.max(newXp - xpRequired, 0);
+			channel.send(
+				`${member.toString()} subiu para o nível ${expStats.level}!`,
+			);
 		}
 
 		await container.db.user.upsert({
diff --git a/src/utils/dateFormat.ts b/src/utils/dateFormat.ts
index 2476ff1..90f4eee 100644
--- a/src/utils/dateFormat.ts
+++ b/src/utils/dateFormat.ts
@@ -20,9 +20,9 @@ export function dateToCron(dateStr: string) {
 
 export function formatDate(date: DateObject): string {
 	const parts = [
-		String(date.day).padStart(2, "0"),
-		String(date.month).padStart(2, "0"),
-		String(date.year),
+		date.day ? String(date.day).padStart(2, "0") : undefined,
+		date.month ? String(date.month).padStart(2, "0") : undefined,
+		date.year ? String(date.year) : undefined,
 	].filter((part) => part);
 
 	return parts.join("/");

From cb7f8da3b6f2f5d29bc56be33ab6eb7999d51056 Mon Sep 17 00:00:00 2001
From: Gabriel Tavares <bieltmd@outlook.com>
Date: Sun, 9 Jun 2024 21:56:36 -0400
Subject: [PATCH 2/4] feat: scheduled events

Co-authored-by: Psykka <Psykka@users.noreply.github.com>
---
 docker/Dockerfile                             |  5 ++
 docker/Dockerfile.backup                      |  5 +-
 package.json                                  |  5 +-
 pnpm-lock.yaml                                | 22 ++++++++
 .../migration.sql                             | 28 ++++++++++
 .../20240610010812_event_type/migration.sql   | 27 +++++++++
 prisma/schema.prisma                          | 16 +++---
 src/commands/user/avatar.ts                   |  3 -
 src/events/announceScheduledEvent.ts          | 56 +++++++++++++++++++
 src/events/startTimers.ts                     | 15 +++++
 src/listeners/ready.ts                        |  5 +-
 11 files changed, 172 insertions(+), 15 deletions(-)
 create mode 100644 prisma/migrations/20240610005557_update_date_and_fields/migration.sql
 create mode 100644 prisma/migrations/20240610010812_event_type/migration.sql
 create mode 100644 src/events/announceScheduledEvent.ts
 create mode 100644 src/events/startTimers.ts

diff --git a/docker/Dockerfile b/docker/Dockerfile
index 2ce0258..a822e33 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -16,6 +16,11 @@ RUN pnpm build
 
 FROM node:20-alpine as runner
 
+ENV TZ America/Sao_Paulo
+
+RUN apk add --no-cache tzdata
+RUN ln -s /usr/share/zoneinfo/${TZ} /etc/localtime
+
 WORKDIR /app
 
 COPY --from=builder /app/package.json ./
diff --git a/docker/Dockerfile.backup b/docker/Dockerfile.backup
index 7713ba6..37f571e 100644
--- a/docker/Dockerfile.backup
+++ b/docker/Dockerfile.backup
@@ -1,8 +1,9 @@
 FROM alpine:latest
 
-RUN apk add --no-cache sqlite curl tzdata tar
+ENV TZ America/Sao_Paulo
 
-ENV TZ=America/Sao_Paulo
+RUN apk add --no-cache sqlite curl tzdata tar
+RUN ln -s /usr/share/zoneinfo/${TZ} /etc/localtime
 
 COPY scripts/backup.sh /usr/local/bin/backup.sh
 COPY scripts/crontab /etc/crontabs/root
diff --git a/package.json b/package.json
index 3e3f065..ef7cde0 100644
--- a/package.json
+++ b/package.json
@@ -30,12 +30,13 @@
 		"typescript": "^5.4.5"
 	},
 	"dependencies": {
-		"prisma": "^5.14.0",
 		"@prisma/client": "5.14.0",
 		"@sapphire/discord.js-utilities": "^7.2.1",
 		"@sapphire/framework": "^5.2.1",
 		"@sapphire/pieces": "^4.2.2",
+		"cron": "^3.1.7",
 		"dayjs": "^1.11.11",
-		"discord.js": "14.x"
+		"discord.js": "14.x",
+		"prisma": "^5.14.0"
 	}
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cabfa52..40744b8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
       '@sapphire/pieces':
         specifier: ^4.2.2
         version: 4.2.2
+      cron:
+        specifier: ^3.1.7
+        version: 3.1.7
       dayjs:
         specifier: ^1.11.11
         version: 1.11.11
@@ -347,6 +350,9 @@ packages:
   '@types/keyv@3.1.4':
     resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
 
+  '@types/luxon@3.4.2':
+    resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
+
   '@types/node@20.12.12':
     resolution: {integrity: sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==}
 
@@ -453,6 +459,9 @@ packages:
     resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
     engines: {node: '>= 0.6'}
 
+  cron@3.1.7:
+    resolution: {integrity: sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==}
+
   cross-spawn@5.1.0:
     resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
 
@@ -693,6 +702,10 @@ packages:
   lru-cache@4.1.5:
     resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==}
 
+  luxon@3.4.4:
+    resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==}
+    engines: {node: '>=12'}
+
   magic-bytes.js@1.10.0:
     resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==}
 
@@ -1345,6 +1358,8 @@ snapshots:
     dependencies:
       '@types/node': 20.12.12
 
+  '@types/luxon@3.4.2': {}
+
   '@types/node@20.12.12':
     dependencies:
       undici-types: 5.26.5
@@ -1455,6 +1470,11 @@ snapshots:
     dependencies:
       safe-buffer: 5.2.1
 
+  cron@3.1.7:
+    dependencies:
+      '@types/luxon': 3.4.2
+      luxon: 3.4.4
+
   cross-spawn@5.1.0:
     dependencies:
       lru-cache: 4.1.5
@@ -1709,6 +1729,8 @@ snapshots:
       pseudomap: 1.0.2
       yallist: 2.1.2
 
+  luxon@3.4.4: {}
+
   magic-bytes.js@1.10.0: {}
 
   merge-stream@2.0.0: {}
diff --git a/prisma/migrations/20240610005557_update_date_and_fields/migration.sql b/prisma/migrations/20240610005557_update_date_and_fields/migration.sql
new file mode 100644
index 0000000..9c31f14
--- /dev/null
+++ b/prisma/migrations/20240610005557_update_date_and_fields/migration.sql
@@ -0,0 +1,28 @@
+/*
+  Warnings:
+
+  - You are about to drop the column `date` on the `Events` table. All the data in the column will be lost.
+  - Added the required column `day` to the `Events` table without a default value. This is not possible if the table is not empty.
+  - Added the required column `month` to the `Events` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- RedefineTables
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_Events" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "description" TEXT NOT NULL,
+    "month" INTEGER NOT NULL,
+    "day" INTEGER NOT NULL,
+    "repeat" BOOLEAN NOT NULL DEFAULT false,
+    "guild_id" TEXT,
+    "created_by" TEXT,
+    "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updated_at" DATETIME NOT NULL,
+    CONSTRAINT "Events_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "Guild" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+    CONSTRAINT "Events_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "User" ("discord_id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+INSERT INTO "new_Events" ("created_at", "created_by", "description", "guild_id", "id", "repeat", "updated_at") SELECT "created_at", "created_by", "description", "guild_id", "id", "repeat", "updated_at" FROM "Events";
+DROP TABLE "Events";
+ALTER TABLE "new_Events" RENAME TO "Events";
+PRAGMA foreign_key_check("Events");
+PRAGMA foreign_keys=ON;
diff --git a/prisma/migrations/20240610010812_event_type/migration.sql b/prisma/migrations/20240610010812_event_type/migration.sql
new file mode 100644
index 0000000..495c838
--- /dev/null
+++ b/prisma/migrations/20240610010812_event_type/migration.sql
@@ -0,0 +1,27 @@
+/*
+  Warnings:
+
+  - Added the required column `type` to the `Events` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- RedefineTables
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_Events" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "description" TEXT NOT NULL,
+    "month" INTEGER NOT NULL,
+    "day" INTEGER NOT NULL,
+    "type" TEXT NOT NULL,
+    "repeat" BOOLEAN NOT NULL DEFAULT false,
+    "guild_id" TEXT,
+    "created_by" TEXT,
+    "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updated_at" DATETIME NOT NULL,
+    CONSTRAINT "Events_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "Guild" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+    CONSTRAINT "Events_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "User" ("discord_id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+INSERT INTO "new_Events" ("created_at", "created_by", "day", "description", "guild_id", "id", "month", "repeat", "updated_at") SELECT "created_at", "created_by", "day", "description", "guild_id", "id", "month", "repeat", "updated_at" FROM "Events";
+DROP TABLE "Events";
+ALTER TABLE "new_Events" RENAME TO "Events";
+PRAGMA foreign_key_check("Events");
+PRAGMA foreign_keys=ON;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9ef1708..6571c69 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -16,7 +16,7 @@ model User {
     xp        Int       @default(0) @map("xp")
     level     Int       @default(1) @map("level")
     birthday  DateTime? @map("birthday")
-    Events    Events[]  @relation("UserEvents")
+    events    Events[]  @relation("user_events")
     createdAt DateTime  @default(now()) @map("created_at")
     updatedAt DateTime  @updatedAt @map("updated_at")
 }
@@ -24,12 +24,14 @@ model User {
 model Events {
     id          String   @id @default(cuid()) @map("id")
     description String   @map("description")
-    date        DateTime @map("date")
+    month       Int      @map("month")
+    day         Int      @map("day")
+    type        String   @map("type")
     repeat      Boolean  @default(false) @map("repeat")
     guildId     String?  @map("guild_id")
-    Guild       Guild?   @relation("GuildEvents", fields: [guildId], references: [id])
+    guild       Guild?   @relation("guild_events", fields: [guildId], references: [id])
     createdBy   String?  @map("created_by")
-    User        User?    @relation("UserEvents", fields: [createdBy], references: [discordId])
+    user        User?    @relation("user_events", fields: [createdBy], references: [discordId])
     createdAt   DateTime @default(now()) @map("created_at")
     updatedAt   DateTime @updatedAt @map("updated_at")
 }
@@ -39,15 +41,15 @@ model Daily {
     day       Int     @default(0) @map("day")
     afternoon Int     @default(0) @map("afternoon")
     night     Int     @default(0) @map("night")
-    Guild     Guild[]
+    guild     Guild[]
 }
 
 model Guild {
     id          String   @id @default(cuid()) @map("id")
     discordId   String   @unique @map("discord_id")
     mainChannel String?  @map("main_channel")
-    Events      Events[] @relation("GuildEvents")
-    Daily       Daily?   @relation(fields: [dailyId], references: [id])
+    events      Events[] @relation("guild_events")
+    daily       Daily?   @relation(fields: [dailyId], references: [id])
     dailyId     String?  @map("daily_id")
     createdAt   DateTime @default(now()) @map("created_at")
     updatedAt   DateTime @updatedAt @map("updated_at")
diff --git a/src/commands/user/avatar.ts b/src/commands/user/avatar.ts
index 8f5771b..123a375 100644
--- a/src/commands/user/avatar.ts
+++ b/src/commands/user/avatar.ts
@@ -58,8 +58,6 @@ export class AvatarCommand extends Command {
 		};
 
 		const userType = interaction.options.getString("type");
-		const avatarType =
-			userType === "user" || !member.avatar ? "Usuário" : "Servidor";
 		const avatar =
 			userType === "user"
 				? member.user.displayAvatarURL(config)
@@ -68,7 +66,6 @@ export class AvatarCommand extends Command {
 		const embed = new EmbedBuilder()
 			.setTitle(nickname)
 			.setImage(avatar)
-			.setFooter({ text: avatarType })
 			.setColor(color);
 
 		await interaction.reply({
diff --git a/src/events/announceScheduledEvent.ts b/src/events/announceScheduledEvent.ts
new file mode 100644
index 0000000..d17fb87
--- /dev/null
+++ b/src/events/announceScheduledEvent.ts
@@ -0,0 +1,56 @@
+import { container } from "@sapphire/pieces";
+import type { TextChannel } from "discord.js";
+
+type ChannelMessage = {
+	channel: TextChannel;
+	message: string[];
+};
+
+export default async function announceScheduledEvent() {
+	const today = new Date();
+	const events = await container.db.events.findMany({
+		where: {
+			month: today.getMonth() + 1,
+			day: today.getDate(),
+			type: "DEFAULT",
+		},
+		include: {
+			guild: true,
+		},
+	});
+
+	if (!events || events.length <= 0) return;
+
+	const guilds = container.client.guilds.cache;
+	const channelMessages = new Map<string, ChannelMessage>();
+
+	for (const event of events) {
+		if (!event.guildId || !event.guild?.mainChannel) continue;
+
+		const guild = guilds.get(event.guild.discordId);
+		const channel = guild?.channels.cache.get(event.guild.mainChannel) as
+			| TextChannel
+			| undefined;
+
+		if (!channel || !guild) continue;
+
+		if (channelMessages.has(channel.id)) {
+			const messages = channelMessages.get(channel.id)?.message;
+			messages?.push(`- ${event.description}`);
+		} else {
+			channelMessages.set(channel.id, {
+				channel,
+				message: [`- ${event.description}`],
+			});
+		}
+
+		if (!event.repeat) {
+			await container.db.events.delete({ where: { id: event.id } });
+		}
+	}
+
+	for (const channelMessage of channelMessages.values()) {
+		const channel = channelMessage.channel;
+		channel.send(`# Eventos\n${channelMessage.message.join("\n")}`);
+	}
+}
diff --git a/src/events/startTimers.ts b/src/events/startTimers.ts
new file mode 100644
index 0000000..56fb5a2
--- /dev/null
+++ b/src/events/startTimers.ts
@@ -0,0 +1,15 @@
+import { CronJob } from "cron";
+import announceScheduledEvent from "./announceScheduledEvent.js";
+import { defaultTimeZone } from "@/utils/contants.js";
+
+export default function startTimers() {
+	new CronJob(
+		"* * * * *",
+		() => {
+			announceScheduledEvent();
+		},
+		null,
+		true,
+		defaultTimeZone,
+	);
+}
diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts
index fc0b1fe..ba7552d 100644
--- a/src/listeners/ready.ts
+++ b/src/listeners/ready.ts
@@ -1,3 +1,4 @@
+import startTimers from "@/events/startTimers.js";
 import { Listener } from "@sapphire/framework";
 import type { Client } from "discord.js";
 
@@ -13,5 +14,7 @@ export class ReadyListener extends Listener {
 		});
 	}
 
-	public run(client: Client) {}
+	public run(client: Client) {
+		startTimers();
+	}
 }

From 169f61f2a259ff4bd09f614e9f88f0a005d21868 Mon Sep 17 00:00:00 2001
From: Gabriel Tavares <bieltmd@outlook.com>
Date: Sun, 9 Jun 2024 21:59:21 -0400
Subject: [PATCH 3/4] fix: imports

---
 src/events/startTimers.ts | 2 +-
 src/utils/contants.ts     | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/events/startTimers.ts b/src/events/startTimers.ts
index 56fb5a2..73b1782 100644
--- a/src/events/startTimers.ts
+++ b/src/events/startTimers.ts
@@ -1,6 +1,6 @@
+import { defaultTimeZone } from "@/utils/contants.js";
 import { CronJob } from "cron";
 import announceScheduledEvent from "./announceScheduledEvent.js";
-import { defaultTimeZone } from "@/utils/contants.js";
 
 export default function startTimers() {
 	new CronJob(
diff --git a/src/utils/contants.ts b/src/utils/contants.ts
index a0c0ab0..104de60 100644
--- a/src/utils/contants.ts
+++ b/src/utils/contants.ts
@@ -3,6 +3,8 @@ export const EmbedColors = {
 	anilist: 0x3db4f2,
 };
 
+export const defaultTimeZone = "America/Sao_Paulo";
+
 export const leaderboardIcon = "https://i.imgur.com/qpb2q9S.png";
 
 export const leaderboardEmojis = ["🥇", "🥈", "🥉"];

From c860b48623be04392bd6c01da0671693daed96db Mon Sep 17 00:00:00 2001
From: Gabriel Tavares <bieltmd@outlook.com>
Date: Sun, 9 Jun 2024 22:17:04 -0400
Subject: [PATCH 4/4] chore: message formatting

---
 src/events/announceScheduledEvent.ts | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/events/announceScheduledEvent.ts b/src/events/announceScheduledEvent.ts
index d17fb87..1a60dd3 100644
--- a/src/events/announceScheduledEvent.ts
+++ b/src/events/announceScheduledEvent.ts
@@ -36,11 +36,11 @@ export default async function announceScheduledEvent() {
 
 		if (channelMessages.has(channel.id)) {
 			const messages = channelMessages.get(channel.id)?.message;
-			messages?.push(`- ${event.description}`);
+			messages?.push(event.description);
 		} else {
 			channelMessages.set(channel.id, {
 				channel,
-				message: [`- ${event.description}`],
+				message: [event.description],
 			});
 		}
 
@@ -51,6 +51,8 @@ export default async function announceScheduledEvent() {
 
 	for (const channelMessage of channelMessages.values()) {
 		const channel = channelMessage.channel;
-		channel.send(`# Eventos\n${channelMessage.message.join("\n")}`);
+		channel.send(
+			`# Eventos\n${channelMessage.message.map((m) => `- ${m}`).join("\n")}`,
+		);
 	}
 }