diff --git a/apps/bot/src/client.ts b/apps/bot/src/client.ts index 8a38572..e7a770e 100644 --- a/apps/bot/src/client.ts +++ b/apps/bot/src/client.ts @@ -31,6 +31,7 @@ export class MistSapphireClient extends SapphireClient { this.stores.registerPath(join(this.rootData.root, "modules/debug")); this.stores.registerPath(join(this.rootData.root, "modules/managed-roles")); this.stores.registerPath(join(this.rootData.root, "modules/tickets")); + this.stores.registerPath(join(this.rootData.root, "modules/content")); } public override async login(token?: string) { diff --git a/apps/bot/src/modules/boards/listeners/thread-create.ts b/apps/bot/src/modules/boards/listeners/thread-create.ts index ea2c0c1..1611bc8 100644 --- a/apps/bot/src/modules/boards/listeners/thread-create.ts +++ b/apps/bot/src/modules/boards/listeners/thread-create.ts @@ -34,6 +34,8 @@ export class BoardsThreadCreateListener extends Listener<"threadCreate"> { "Channel is not a board channel" ); + + const status = config.tags.find( (tag) => tag.isStatus && diff --git a/apps/bot/src/modules/content/functions/config.ts b/apps/bot/src/modules/content/functions/config.ts new file mode 100644 index 0000000..8f73d11 --- /dev/null +++ b/apps/bot/src/modules/content/functions/config.ts @@ -0,0 +1,18 @@ +import { container } from "@sapphire/pieces"; + + + +// obecne obslugiwany jest tylko jeden kanał +export async function getChannelWithEnabledContentFetching() { + const config = await container.db.contentChannelConfig.findFirst({ + where: { + enabled: true, + }, + select: { + channelId: true, + }, + }); + + return config + +} diff --git a/apps/bot/src/modules/content/functions/deleteAllAttachments.ts b/apps/bot/src/modules/content/functions/deleteAllAttachments.ts new file mode 100644 index 0000000..57dc593 --- /dev/null +++ b/apps/bot/src/modules/content/functions/deleteAllAttachments.ts @@ -0,0 +1,15 @@ +import { container } from "@sapphire/pieces"; +import type { Message } from "discord.js"; + +export async function deleteAllAttachments(message: Message) { + try { + await container.db.contentCommentAttachment.deleteMany({ + where: { + commentId: BigInt(message.id), + }, + }); + console.log('Wszystkie załączniki dla komentarza usunięte.'); + } catch (error) { + console.error('Błąd podczas usuwania załączników:', error); + } +} diff --git a/apps/bot/src/modules/content/functions/deleteAllReactions.ts b/apps/bot/src/modules/content/functions/deleteAllReactions.ts new file mode 100644 index 0000000..5b6aecd --- /dev/null +++ b/apps/bot/src/modules/content/functions/deleteAllReactions.ts @@ -0,0 +1,18 @@ +import { container } from '@sapphire/pieces'; +import type { Message } from 'discord.js'; + +export async function deleteAllReactions(message: Message) { + const messageId = BigInt(message.id); + + try { + await container.db.contentCommentReactions.deleteMany({ + where: { + commentId: messageId, + }, + }); + + console.log(`Wszystkie reakcje dla wiadomości o ID ${message.id} zostały usunięte.`); + } catch (error) { + console.error('Błąd podczas usuwania reakcji:', error); + } +} diff --git a/apps/bot/src/modules/content/functions/deleteAllThreadTags.ts b/apps/bot/src/modules/content/functions/deleteAllThreadTags.ts new file mode 100644 index 0000000..3208427 --- /dev/null +++ b/apps/bot/src/modules/content/functions/deleteAllThreadTags.ts @@ -0,0 +1,16 @@ +// deleteAllThreadTags.ts +import { container } from "@sapphire/pieces"; + + +export async function deleteAllThreadTags(threadId: bigint): Promise { + try { + await container.db.threadTag.deleteMany({ + where: { + threadId: threadId, + }, + }); + console.log(`Usunięto wszystkie tagi dla wątku ID: ${threadId}`); + } catch (error) { + console.error(`Błąd podczas usuwania tagów dla wątku ID: ${threadId}:`, error); + } +} diff --git a/apps/bot/src/modules/content/functions/getThreadAuthorUsername.ts b/apps/bot/src/modules/content/functions/getThreadAuthorUsername.ts new file mode 100644 index 0000000..d829e7f --- /dev/null +++ b/apps/bot/src/modules/content/functions/getThreadAuthorUsername.ts @@ -0,0 +1,26 @@ +import type { ThreadChannel } from "discord.js"; + + + +export async function getThreadAuthorUsername(thread: ThreadChannel): Promise { + + if (!thread.ownerId) { + console.error("Wątek nie ma przypisanego właściciela (ownerId)."); + return null; + } + + try { + + const owner = thread.client.users.cache.get(thread.ownerId); + + if (owner) { + return owner.username; + } + + const fetchedOwner = await thread.client.users.fetch(thread.ownerId); + return fetchedOwner.username; + } catch (error) { + console.error("Błąd podczas pobierania autora wątku:", error); + return null; + } +} diff --git a/apps/bot/src/modules/content/functions/upsertAttachment.ts b/apps/bot/src/modules/content/functions/upsertAttachment.ts new file mode 100644 index 0000000..a209745 --- /dev/null +++ b/apps/bot/src/modules/content/functions/upsertAttachment.ts @@ -0,0 +1,27 @@ +import { container } from "@sapphire/pieces"; +import type { Attachment, Message } from "discord.js"; + +export async function upsertAttachment(attachment: Attachment, message: Message) { + try { + await container.db.contentCommentAttachment.upsert({ + where: { id: BigInt(attachment.id) }, + update: { + commentId: BigInt(message.id), + url: attachment.url, + filename: attachment.name, + updatedAt: new Date(), + }, + create: { + id: BigInt(attachment.id), + commentId: BigInt(message.id), + url: attachment.url, + filename: attachment.name, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + console.log('Załącznik upsertowany:'); + } catch (error) { + console.error('Błąd podczas upsertowania załącznika:', error); + } +} diff --git a/apps/bot/src/modules/content/functions/upsertComment.ts b/apps/bot/src/modules/content/functions/upsertComment.ts new file mode 100644 index 0000000..4a3a241 --- /dev/null +++ b/apps/bot/src/modules/content/functions/upsertComment.ts @@ -0,0 +1,34 @@ +import { container } from "@sapphire/pieces"; +import type { Message, ThreadChannel } from "discord.js"; +//import { getThreadAuthorUsername } from "./getThreadAuthorUsername.js"; + +export async function upsertComment(message: Message, thread: ThreadChannel) { + + const author = message.author.username; + + if (!author) { + throw new Error("Wątek nie ma przypisanego właściciela (ownerId) lub ten nie znajduje się w cache."); + } + + try { + await container.db.contentComments.upsert({ + where: { id: BigInt(message.id) }, + update: { + content: message.content, + author, + updatedAt: new Date(), + }, + create: { + id: BigInt(message.id), + threadId: BigInt(thread.id), + content: message.content, + author, + createdAt: message.createdAt ?? new Date(), + updatedAt: new Date(), + }, + }); + console.log('Wątek upsertowany:'); + } catch (error) { + console.error('Błąd podczas upsertowania wątku', error); + } +} diff --git a/apps/bot/src/modules/content/functions/upsertReactions.ts b/apps/bot/src/modules/content/functions/upsertReactions.ts new file mode 100644 index 0000000..250c446 --- /dev/null +++ b/apps/bot/src/modules/content/functions/upsertReactions.ts @@ -0,0 +1,57 @@ +import { container } from '@sapphire/pieces'; +import type { MessageReaction, User, ThreadChannel } from 'discord.js'; + +export async function upsertReaction( + reaction: MessageReaction, + user: User, + thread: ThreadChannel, + action: 'add' | 'remove' +) { + const message = reaction.message; + const emoji = reaction.emoji.identifier; + + if (!emoji) { + console.error('Nie udało się uzyskać identyfikatora emoji.'); + return; + } + + try { + + + if (action === 'add') { + await container.db.contentCommentReactions.upsert({ + where: { + commentId_emoji_userId: { + commentId: BigInt(message.id), + emoji, + userId: BigInt(user.id), + }, + }, + update: { + updatedAt: new Date(), + }, + create: { + commentId: BigInt(message.id), + emoji, + userId: BigInt(user.id), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + console.log('Reakcja dodana do bazy danych.'); + } else if (action === 'remove') { + await container.db.contentCommentReactions.delete({ + where: { + commentId_emoji_userId: { + commentId: BigInt(message.id), + emoji, + userId: BigInt(user.id), + }, + }, + }); + console.log('Reakcja usunięta z bazy danych.'); + } + } catch (error) { + console.error('Błąd podczas upsertowania reakcji', error); + } +} diff --git a/apps/bot/src/modules/content/functions/upsertTag.ts b/apps/bot/src/modules/content/functions/upsertTag.ts new file mode 100644 index 0000000..a6cb71c --- /dev/null +++ b/apps/bot/src/modules/content/functions/upsertTag.ts @@ -0,0 +1,35 @@ +// upsertTag.ts +import { container } from "@sapphire/pieces"; +import type { GuildForumTag } from "discord.js"; +import { assert } from "../../../utils/assert.js"; + +interface UpsertTagParams { + tag: GuildForumTag; + channelId: bigint; +} + +export async function upsertTag({ tag, channelId }: UpsertTagParams) { + try { + await container.db.contentTag.upsert({ + where: { + id: tag.id, + }, + update: { + name: tag.name, + emoji: tag.emoji ? tag.emoji.name : null, + updatedAt: new Date(), + }, + create: { + id: tag.id, + channelId: channelId, + name: tag.name, + emoji: tag.emoji ? tag.emoji.name : null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + console.log(`Tag upsertowany: ${tag.name}`); + } catch (error) { + console.error(`Błąd podczas upsertowania tagu "${tag.name}":`, error); + } +} diff --git a/apps/bot/src/modules/content/functions/upsertTagsForThread.ts b/apps/bot/src/modules/content/functions/upsertTagsForThread.ts new file mode 100644 index 0000000..d36a1cb --- /dev/null +++ b/apps/bot/src/modules/content/functions/upsertTagsForThread.ts @@ -0,0 +1,28 @@ +// upsertTagsForThread.ts +import { container } from "@sapphire/pieces"; + +interface AssignTagsToThreadParams { + threadId: bigint; + tagIds: string[]; +} + + +export async function upsertTagsForThread({ threadId, tagIds }: AssignTagsToThreadParams): Promise { + try { + const createThreadTags = tagIds.map((tagId) => ({ + threadId: threadId, + tagId: tagId, + })); + + if (createThreadTags.length > 0) { + await container.db.threadTag.createMany({ + data: createThreadTags, + }); + console.log(`Przypisano ${createThreadTags.length} tagi do wątku ID: ${threadId}`); + } else { + //console.log(`Nie przypisano żadnych tagów do wątku ID: ${threadId}`); + } + } catch (error) { + console.error(`Błąd podczas przypisywania tagów do wątku ID: ${threadId}:`, error); + } +} diff --git a/apps/bot/src/modules/content/functions/upsertThread.ts b/apps/bot/src/modules/content/functions/upsertThread.ts new file mode 100644 index 0000000..5d33b2b --- /dev/null +++ b/apps/bot/src/modules/content/functions/upsertThread.ts @@ -0,0 +1,33 @@ +import { container } from "@sapphire/pieces"; +import type { ThreadChannel } from "discord.js"; +import { getThreadAuthorUsername } from "./getThreadAuthorUsername.js"; + +export async function upsertThread(thread: ThreadChannel) { + + const author = await getThreadAuthorUsername(thread); + + if (!author) { + throw new Error("Wątek nie ma przypisanego właściciela (ownerId) lub ten nie znajduje się w cache."); + } + + try { + await container.db.contentThreads.upsert({ + where: { id: BigInt(thread.id) }, + update: { + content: thread.name, + author, + updatedAt: new Date(), + }, + create: { + id: BigInt(thread.id), + content: thread.name, + author, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + console.log(`Wątek upsertowany: ${thread.name}`); + } catch (error) { + console.error(`Błąd podczas upsertowania wątku "${thread.name}":`, error); + } +} diff --git a/apps/bot/src/modules/content/listeners/message-create.ts b/apps/bot/src/modules/content/listeners/message-create.ts new file mode 100644 index 0000000..023f891 --- /dev/null +++ b/apps/bot/src/modules/content/listeners/message-create.ts @@ -0,0 +1,38 @@ +import { Listener } from '@sapphire/framework'; +import type { Message, ThreadChannel } from 'discord.js'; +import { getChannelWithEnabledContentFetching } from '../functions/config.js'; +import { upsertComment } from '../functions/upsertComment.js'; + +export class MessageCreateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { + ...options, + event: 'messageCreate', + }); + } + + public async run(message: Message) { + if (message.partial) await message.fetch(); + + if (!message.channel.isThread()) return; + + const thread = message.channel as ThreadChannel; + + const result = await getChannelWithEnabledContentFetching(); + + if (!result) { + console.error('Nie znaleziono konfiguracji kanału.'); + return; + } + + const channelId = result.channelId; + + if (thread.parentId !== channelId.toString()){ + return; + } + + console.log('message was created', message.content); + + await upsertComment(message, thread); + } +} diff --git a/apps/bot/src/modules/content/listeners/message-reaction-create.ts b/apps/bot/src/modules/content/listeners/message-reaction-create.ts new file mode 100644 index 0000000..10f91d9 --- /dev/null +++ b/apps/bot/src/modules/content/listeners/message-reaction-create.ts @@ -0,0 +1,48 @@ +import { Listener } from '@sapphire/framework'; +import type { MessageReaction, User, ThreadChannel } from 'discord.js'; +import { getChannelWithEnabledContentFetching } from '../functions/config.js'; +import { upsertReaction } from '../functions/upsertReactions.js'; + +export class MessageReactionAddListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { + ...options, + event: 'messageReactionAdd', + }); + } + + public async run(reaction: MessageReaction, user: User) { + // Upewnij się, że reakcja i wiadomość są w pełni załadowane + if (reaction.partial) await reaction.fetch(); + if (reaction.message.partial) await reaction.message.fetch(); + + const message = reaction.message; + + // Sprawdź, czy wiadomość jest w wątku + if (!message.channel.isThread()) return; + + const thread = message.channel as ThreadChannel; + + const result = await getChannelWithEnabledContentFetching(); + + if (!result) { + console.error('Nie znaleziono konfiguracji kanału.'); + return; + } + + const channelId = result.channelId; + + // Sprawdź, czy wątek należy do wybranego kanału + if (thread.parentId !== channelId.toString()) return; + + // Tutaj możesz wywołać kod, który ma być wykonany po dodaniu reakcji + console.log( + `Użytkownik ${user.tag} dodał reakcję ${reaction.emoji.name} do wiadomości ${message.id}` + ); + + await upsertReaction(reaction, user, thread, 'add'); + + // Jeśli chcesz zaktualizować bazę danych lub wykonać inne operacje, możesz to zrobić tutaj + // Przykład: await upsertReaction(reaction, user, thread); + } +} diff --git a/apps/bot/src/modules/content/listeners/message-reaction-remove.ts b/apps/bot/src/modules/content/listeners/message-reaction-remove.ts new file mode 100644 index 0000000..dc34851 --- /dev/null +++ b/apps/bot/src/modules/content/listeners/message-reaction-remove.ts @@ -0,0 +1,42 @@ +// messageReactionRemove.ts +import { Listener } from '@sapphire/framework'; +import type { MessageReaction, User, ThreadChannel } from 'discord.js'; +import { getChannelWithEnabledContentFetching } from '../functions/config.js'; +import { upsertReaction } from '../functions/upsertReactions.js'; + +export class MessageReactionRemoveListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { + ...options, + event: 'messageReactionRemove', + }); + } + + public async run(reaction: MessageReaction, user: User) { + // Upewnij się, że reakcja i wiadomość są w pełni załadowane + if (reaction.partial) await reaction.fetch(); + if (reaction.message.partial) await reaction.message.fetch(); + + const message = reaction.message; + + // Sprawdź, czy wiadomość jest w wątku + if (!message.channel.isThread()) return; + + const thread = message.channel as ThreadChannel; + + const result = await getChannelWithEnabledContentFetching(); + + if (!result) { + console.error('Nie znaleziono konfiguracji kanału.'); + return; + } + + const channelId = result.channelId; + + // Sprawdź, czy wątek należy do wybranego kanału + if (thread.parentId !== channelId.toString()) return; + + // Wywołaj funkcję upsertReaction z akcją 'remove' + await upsertReaction(reaction, user, thread, 'remove'); + } +} diff --git a/apps/bot/src/modules/content/listeners/message-update.ts b/apps/bot/src/modules/content/listeners/message-update.ts new file mode 100644 index 0000000..db7ce9f --- /dev/null +++ b/apps/bot/src/modules/content/listeners/message-update.ts @@ -0,0 +1,51 @@ +import { Listener } from '@sapphire/framework'; +import type { Message, ThreadChannel } from 'discord.js'; +import { getChannelWithEnabledContentFetching } from '../functions/config.js'; +import { upsertComment } from '../functions/upsertComment.js'; + +export class MessageUpdateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { + ...options, + event: 'messageUpdate', + }); + } + + public async run(oldMessage: Message, newMessage: Message) { + if (newMessage.partial) await newMessage.fetch(); + + if (!newMessage.channel.isThread()) return; + + const thread = newMessage.channel as ThreadChannel; + + const result = await getChannelWithEnabledContentFetching(); + + if (!result) { + console.error('Nie znaleziono konfiguracji kanału.'); + return; + } + + const channelId = result.channelId; + + if (thread.parentId !== channelId.toString()) { + return; + } + + console.log('message was updated', newMessage.content); + + + + if (newMessage.reactions.cache.size > 0) { + console.log('Reactions found for the updated message:'); + + + for (const [emoji, reaction] of newMessage.reactions.cache) { + console.log(`Reaction: ${reaction.emoji.name}, Count: ${reaction.count}`); + } + } else { + console.log('No reactions found for the updated message.'); + } + + await upsertComment(newMessage, thread); + } +} diff --git a/apps/bot/src/modules/content/listeners/thread-create.ts b/apps/bot/src/modules/content/listeners/thread-create.ts new file mode 100644 index 0000000..9bf2f47 --- /dev/null +++ b/apps/bot/src/modules/content/listeners/thread-create.ts @@ -0,0 +1,32 @@ + +import { Listener } from '@sapphire/framework'; +import type { ThreadChannel } from 'discord.js'; +import { getChannelWithEnabledContentFetching } from '../functions/config.js'; +import { upsertThread } from '../functions/upsertThread.js'; + +export class ThreadCreateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { + ...options, + event: 'threadCreate', + }); + } + + public async run(thread: ThreadChannel) { + const result = await getChannelWithEnabledContentFetching(); + + if (!result) { + console.error('Nie znaleziono konfiguracji kanału.'); + return; + } + + + if (thread.parentId !== result.toString()) { + return; + } + + console.log('thread was updated', thread.name); + + await upsertThread(thread); + } +} diff --git a/apps/bot/src/modules/content/listeners/thread-ready.ts b/apps/bot/src/modules/content/listeners/thread-ready.ts new file mode 100644 index 0000000..e3ad671 --- /dev/null +++ b/apps/bot/src/modules/content/listeners/thread-ready.ts @@ -0,0 +1,179 @@ +import { Listener } from "@sapphire/framework"; +import { + TextChannel, + NewsChannel, + type ThreadChannel, + Collection, + ChannelType, + type FetchedThreads, + type ForumChannel, + // type AnyThreadChannel, +} from "discord.js"; +import { getChannelWithEnabledContentFetching } from "../functions/config.js"; +//import { getThreadAuthorUsername } from "../functions/getThreadAuthorUsername.js"; +import { upsertThread } from "../functions/upsertThread.js"; +import { upsertComment } from "../functions/upsertComment.js"; +import { deleteAllReactions } from "../functions/deleteAllReactions.js"; +import { upsertReaction } from "../functions/upsertReactions.js"; +import { assert } from "../../../utils/assert.js"; +import { upsertTag } from "../functions/upsertTag.js"; +import { deleteAllThreadTags } from "../functions/deleteAllThreadTags.js"; +import { upsertTagsForThread } from "../functions/upsertTagsForThread.js"; +import { deleteAllAttachments } from "../functions/deleteAllAttachments.js"; +import { upsertAttachment } from "../functions/upsertAttachment.js"; +// import { assert } from "../../../utils/assert.js"; + + + +export class BoardsThreadFetchListener extends Listener { + public constructor( + context: Listener.LoaderContext, + options: Listener.Options + ) { + super(context, { + ...options, + event: 'ready', + }); + } + + async run() { + + const result = await getChannelWithEnabledContentFetching(); + + assert(result, "Nie znaleziono konfiguracji kanału."); + + + try { + const channel = await this.container.client.channels.fetch(`${result.channelId}`); + + assert(channel, "Nie znaleziono kanału."); + + //console.log(`Pobrany kanał: ${channel.id}, typ: ${channel.type}`); + + if ( + channel instanceof TextChannel || + channel instanceof NewsChannel || + channel.type === ChannelType.GuildForum + ) { + console.log('Kanał jest poprawnym typem do pobierania wątków.'); + + + const forumChannel = channel as ForumChannel; + const tags = forumChannel.availableTags; + + if (tags.length > 0) { + for (const tag of tags) { + console.log(`Tag: ${tag.name}, Emoji: ${tag.emoji ? tag.emoji.name : 'Brak emoji'} ${tag.id}`); + await upsertTag({ tag, channelId: BigInt(result.channelId) }); + } + } + + + let activeThreads: FetchedThreads | null = null; + let archivedThreads: FetchedThreads | null = null; + + + activeThreads = await channel.threads.fetchActive(); + console.log(`Znaleziono aktywne wątki: ${activeThreads.threads.size}`); + + assert(activeThreads, "Błąd podczas pobierania aktywnych wątków."); + + + + archivedThreads = await channel.threads.fetchArchived(); + console.log(`Znaleziono zarchiwizowane wątki: ${archivedThreads.threads.size}`); + + assert(archivedThreads, "Błąd podczas pobierania zarchiwizowanych wątków."); + + + const allThreads = new Collection(); + + if (activeThreads) { + for (const thread of activeThreads.threads.values()) { + allThreads.set(thread.id, thread); + } + } + + if (archivedThreads) { + for (const thread of archivedThreads.threads.values()) { + allThreads.set(thread.id, thread); + } + } + + console.log(`Łączna liczba wątków: ${allThreads.size}`); + + + assert(allThreads.size > 0, "Nie znaleziono wątków w kanale."); + + // iterowanie po istniejących wątkach, nawet tych zarchiwizowanych + + for (const thread of allThreads.values()) { + //console.log(`Wątek: ${thread}`); + + if (thread.appliedTags) { + console.log(`Tagi wątku: ${thread.appliedTags}`); + } + + + await upsertThread(thread); + + // Zarządzanie tagami wątku + const appliedTagIds = thread.appliedTags || []; + + // Usunięcie wszystkich tagów dla wątku + await deleteAllThreadTags(BigInt(thread.id)); + + // Przypisanie nowych tagów + await upsertTagsForThread({ + threadId: BigInt(thread.id), + tagIds: appliedTagIds, + }); + + + const messages = await thread.messages.fetch(); + //console.log(`Liczba wiadomości w wątku "${thread.name}": ${messages.size}`); + //assert(messages.size > 0, "Brak wiadomości w wątku."); + + + for (const message of messages.values()) { + console.log(`Wiadomość: ${message.content} author: ${message.author.username}`); + await upsertComment(message,thread); + + // Usuń istniejące załączniki dla komentarza + await deleteAllAttachments(message); + + // Przetwarzaj załączniki + const attachments = message.attachments; + if (attachments.size > 0) { + for (const attachment of attachments.values()) { + // Zapisz załącznik do bazy danych + await upsertAttachment(attachment, message); + } + } + + + // aktualizacja wszystkich reakcji + + await deleteAllReactions(message); + const reactions = message.reactions.cache; + + for (const reaction of reactions.values()) { + const users = await reaction.users.fetch(); + + for (const user of users.values()) { + await upsertReaction(reaction, user, thread, 'add'); + } + } + + + } + + } + } else { + console.error("Kanał nie jest typu TextChannel, NewsChannel, GuildForum lub nie obsługuje wątków."); + } + } catch (error) { + console.error("Błąd podczas pobierania kanału lub wątków:", error); + } + } +} diff --git a/apps/bot/src/modules/content/listeners/thread-update.ts b/apps/bot/src/modules/content/listeners/thread-update.ts new file mode 100644 index 0000000..8ab3709 --- /dev/null +++ b/apps/bot/src/modules/content/listeners/thread-update.ts @@ -0,0 +1,32 @@ +import { Listener } from '@sapphire/framework'; +import type { ThreadChannel } from 'discord.js'; +import { getChannelWithEnabledContentFetching } from '../functions/config.js'; +import { upsertThread } from '../functions/upsertThread.js'; + +export class ThreadUpdateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { + ...options, + event: 'threadUpdate', + }); + } + + public async run(oldThread: ThreadChannel, newThread: ThreadChannel) { + const result = await getChannelWithEnabledContentFetching(); + + if (!result) { + console.error('Nie znaleziono konfiguracji kanału.'); + return; + } + + const channelId = result.channelId; + + if (newThread.parentId !== channelId.toString()) { + return; + } + + console.log('thread was updated', newThread.name); + + await upsertThread(newThread); + } +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..77b6770 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "slate", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "~/components", + "utils": "~/lib/utils" + } +} \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 9bfe4a0..029709a 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -5,6 +5,21 @@ await import("./src/env.js"); /** @type {import("next").NextConfig} */ -const config = {}; +const config = { + typescript: { //TODO added until fix all typescript/ biome cosmetic errors + ignoreBuildErrors: true, + }, + eslint: { + ignoreDuringBuilds: true, + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.discordapp.com", + }, + ], + }, +}; export default config; diff --git a/apps/web/package.json b/apps/web/package.json index a43af4b..c6b47d7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,33 +5,62 @@ "type": "module", "scripts": { "build": "next build", - "dev": "next dev", + "dev": "next dev --turbopack", "lint": "next lint && biome check ./src", "lint:fix": "next lint && biome check --apply ./src", "start": "next start" }, "dependencies": { + "@lucia-auth/adapter-prisma": "^4.0.1", "@mist/database": "workspace:*", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "@pilcrowjs/object-parser": "^0.0.4", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.3", "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-table": "^8.20.5", + "arctic": "^2.0.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^4.1.0", "dotenv": "^16.4.5", - "next": "^14.2.4", - "next-safe-action": "^7.1.3", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "lucide-react": "^0.417.0", + "next": "15.0.1", + "next-safe-action": "^7.9.7", + "react": "19.0.0-rc-69d4b800-20241021", + "react-dom": "19.0.0-rc-69d4b800-20241021", "server-only": "^0.0.1", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", "zod": "^3.23.8" }, "devDependencies": { - "@types/eslint": "^8.56.10", - "@types/node": "^20.14.10", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", - "eslint": "^8.57.0", - "eslint-config-next": "^14.2.4", - "postcss": "^8.4.39", - "tailwindcss": "^3.4.3", - "typescript": "^5.5.3" + "@types/eslint": "^8.56.12", + "@types/node": "^20.17.0", + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.1", + "eslint-config-next": "15.0.1", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3" + }, + "pnpm": { + "overrides": { + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" + } } } diff --git a/apps/web/src/app/header.tsx b/apps/web/src/app/header.tsx new file mode 100644 index 0000000..5912e37 --- /dev/null +++ b/apps/web/src/app/header.tsx @@ -0,0 +1,13 @@ +import { Button } from "~/components/ui/button" + +function Header() { + return ( +
+
+ +
+
+ ) +} + +export default Header \ No newline at end of file diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f3873f7..cf6b551 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,15 +1,32 @@ import "~/styles/globals.css"; import { Inter } from "next/font/google"; +import Header from "./header"; +import { SessionProvider } from "~/components/providers/session-provider"; +import { getCurrentSession } from "~/lib/session"; const interFont = Inter({ subsets: ["latin"], variable: "--font-inter" }); -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + + + const { session, user } = await getCurrentSession(); + + console.log('user', user); + + return ( - {children} + + +
+
+ {children} +
+
+ ); } diff --git a/apps/web/src/app/login/github/callback/route.ts b/apps/web/src/app/login/github/callback/route.ts new file mode 100644 index 0000000..38e4a7b --- /dev/null +++ b/apps/web/src/app/login/github/callback/route.ts @@ -0,0 +1,113 @@ +import { generateSessionToken, createSession, setSessionTokenCookie } from "~/lib/session"; +import { github } from "~/lib/oauth"; +import { cookies } from "next/headers"; +import { createUser, getUserFromGitHubId } from "~/lib/user"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { globalGETRateLimit } from "~/lib/request"; + +import type { OAuth2Tokens } from "arctic"; + +export async function GET(request: Request): Promise { + if (!await globalGETRateLimit()) { + return new Response("Too many requests", { + status: 429 + }) + } + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const storedState = (await cookies()).get("github_oauth_state")?.value ?? null; + if (code === null || state === null || storedState === null) { + return new Response("Please restart the process.", { + status: 400 + }); + } + if (state !== storedState) { + return new Response("Please restart the process.", { + status: 400 + }); + } + + let tokens: OAuth2Tokens; + try { + tokens = await github.validateAuthorizationCode(code); + } catch { + // Invalid code or client credentials + return new Response("Please restart the process.", { + status: 400 + }); + } + const githubAccessToken = tokens.accessToken(); + + const userRequest = new Request("https://api.github.com/user"); + userRequest.headers.set("Authorization", `Bearer ${githubAccessToken}`); + const userResponse = await fetch(userRequest); + const userResult: unknown = await userResponse.json(); + const userParser = new ObjectParser(userResult); + + console.log("parsedUser", userParser); + + const githubUserId = userParser.getNumber("id"); + const username = userParser.getString("login"); + + console.log("githubUserId", githubUserId); + console.log("username", username); + + const existingUser = await getUserFromGitHubId(githubUserId); + + console.log("existingUser", existingUser); + + if (existingUser !== null) { + const sessionToken = generateSessionToken(); + + console.log("sessionToken", sessionToken); + const session = await createSession(sessionToken, existingUser.id); + + console.log("session", session); + await setSessionTokenCookie(sessionToken, session.expiresAt); + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); + } + + const emailListRequest = new Request("https://api.github.com/user/emails"); + emailListRequest.headers.set("Authorization", `Bearer ${githubAccessToken}`); + const emailListResponse = await fetch(emailListRequest); + const emailListResult: unknown = await emailListResponse.json(); + if (!Array.isArray(emailListResult) || emailListResult.length < 1) { + return new Response("Please restart the process.", { + status: 400 + }); + } + let email: string | null = null; + for (const emailRecord of emailListResult) { + const emailParser = new ObjectParser(emailRecord); + const primaryEmail = emailParser.getBoolean("primary"); + const verifiedEmail = emailParser.getBoolean("verified"); + if (primaryEmail && verifiedEmail) { + email = emailParser.getString("email"); + } + } + if (email === null) { + return new Response("Please verify your GitHub email address.", { + status: 400 + }); + } + + const user = await createUser(githubUserId, email, username); + console.log("created user", user); + const sessionToken = generateSessionToken(); + console.log("sessionToken", sessionToken); + const session = await createSession(sessionToken, user.id); + console.log("session", session); + await setSessionTokenCookie(sessionToken, session.expiresAt); + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); +} \ No newline at end of file diff --git a/apps/web/src/app/login/github/route.ts b/apps/web/src/app/login/github/route.ts new file mode 100644 index 0000000..b734ad2 --- /dev/null +++ b/apps/web/src/app/login/github/route.ts @@ -0,0 +1,29 @@ +import { generateState } from "arctic"; +import { github } from "~/lib/oauth"; +import { cookies } from "next/headers"; +import { globalGETRateLimit } from "~/lib/request"; + +export async function GET(): Promise { + if (!await globalGETRateLimit()) { + return new Response("Too many requests", { + status: 429 + }) + } + const state = generateState(); + const url = github.createAuthorizationURL(state, ["user:email"]); + + (await cookies()).set("github_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }); + + return new Response(null, { + status: 302, + headers: { + Location: url.toString() + } + }); +} \ No newline at end of file diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b608a15..163a6c4 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,3 +1,59 @@ -export default async function Home() { - return
Hello world!
; + +'use client' + +import { Button } from "~/components/ui/button"; +import { DataTable } from "../components/data-table"; +import { useSession } from "~/components/providers/session-provider"; +import { destroySession } from "~/server/actions/destroySession"; + + +export default function Home() { + + + const { user, session } = useSession(); + + //? Z jakiegos powodu nie mozna zaimportowac enum Role z klienta prismy poniewaz powoduje to bład ktory "nidy nie powinien sie wydarzyć" + + const logOutHandler = async () => { + await destroySession(); + window.location.reload(); + } + + console.log('user', user); + console.log('session', session); + // komponenty zostaną przeniesione do osobnego folderu. + return( +
+
+ { + session && user && user.role === 'ADMIN' ? ( + + + ) : ( + +
+ + +

Welcome!

+ {user && user.role !== 'ADMIN' ? ( +
+
Hello {user.username}, please contact the administrator for access. 🐉
+ +
+ ) : ( + + + + )} + + + +
+ + ) + } + +
+
+ ) } diff --git a/apps/web/src/components/data-table.tsx b/apps/web/src/components/data-table.tsx new file mode 100644 index 0000000..0b8559b --- /dev/null +++ b/apps/web/src/components/data-table.tsx @@ -0,0 +1,490 @@ +"use client" + + +import { + CaretSortIcon, + ChevronDownIcon, + DotsHorizontalIcon, +} from "@radix-ui/react-icons" +import { + type ColumnDef, + type ColumnFiltersState, + type Row, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" + +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from "./ui/multiselect"; + +import { Button } from "./ui/button" +import { Checkbox } from "./ui/checkbox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + // DropdownMenuLabel, + // DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu" +import { Input } from "./ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "./ui/table" + +import { useEffect, useState } from "react"; +import { getTopics } from "~/server/actions/get-topics"; +import { getTags } from "~/server/actions/get-tags"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog" +import Thread from "./thread"; +import ThreadNote from "./notes"; +//import { set } from "zod"; + +interface tagsProps { + id: bigint; + name: string; + emoji: string | null; +} + +type ColumnProps = { + id: bigint; + content: string; + author: string; + createdAt: Date; + updatedAt: Date; + note?: string; + tags: { name: string; emoji: string | null }[]; +}; + + +const multiStringFilterFn = ( + row: Row, + columnId: string, + filterValue: string[] +): boolean => { + if (!Array.isArray(filterValue) || filterValue.length === 0) { + return true; + } + + const tags = row.getValue<{ name: string; emoji: string }[]>(columnId); + + if (!tags || !Array.isArray(tags)) { + return false; + } + + const cellValues = tags.map((tag: { name: string; emoji: string }) => tag.name.toLowerCase()); + + console.log('cell values', cellValues); + console.log('filter values', filterValue); + + + return filterValue.every(value => cellValues.includes(value.toLowerCase())); +}; + + + + + +export function DataTable() { + + useEffect(() => { + void (async () => { + const res = await getTopics(); + if (!res?.data) return; + setData(res.data); + })(); + }, []); + + useEffect(() => { + void (async () => { + const res = await getTags(); + if (!res?.data) return; + + try { + const processedTags: tagsProps[] = res.data.map(tag => ({ + id: BigInt(tag.id), + name: tag.name, + emoji: tag.emoji ?? '', + })); + + setTags(processedTags); + } catch (error) { + console.error(error); + } + })(); + }, []); + + + const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "content", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => ( +
{row.getValue("content")}
+ ), + enableSorting: true, + }, + { + accessorKey: "author", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) =>
{row.getValue("author")}
, + }, + { + accessorKey: "tags", + header: () =>
Tags
, + filterFn: multiStringFilterFn, + cell: ({ row }) => { + const tags = row.getValue("tags") satisfies tagsProps[]; + if (!tags) { + return
; + } + return ( +
+ {tags.map((tag) => ( +
+ {tag.emoji} {tag.name} +
+ ))} +
+ ); + }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ + //row + }) => { + //const payment = row.original + + //TODO tutaj można dodać czynności związane z tematami: zamknięcie, otworzenie, zmiania tagu itp. + + return (null); + + return ( + + + + + + + Test option + + + ) + }, + }, + { + id: "open", + enableHiding: false, + cell: ({ row }) => { + const { id, content } = row.original; + + console.log('ttid', id); + + return ( + + + + + + + {content} + + + + + + + ) + }, + },{ + id: "note", + enableHiding: false, + cell: ({ row }) => { + + const { id, note } = row.original; + + console.log('ttid', id); + // + return ( + + +
+ {note ? (note.length <= 100 ? note : note.slice(0, note.lastIndexOf(' ', 35)) + '...') : + ( + + )} +
+
+ + + Notatka + + + + + +
+ ) + }, + }, + + ] + + const [data, setData] = useState([]) + const [tags, setTags] = useState([]) + + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = + useState({}) + const [rowSelection, setRowSelection] = useState({}) + + const handleNoteUpdate = (id: bigint, newNote: string) => { + setData(prevData => + prevData.map(item => + item.id === id ? { ...item, note: newNote } : item + ) + ); + }; + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }) + + const [valueMultiSelect,setValueMultiSelect ] = useState([]) + + const handleMultiSelect = (value: string[]) => { + setValueMultiSelect(value) + console.log(value) + //table.getColumn("tags")?.setFilterValue(value) + setColumnFilters([{ id: 'tags', value }]); + } + + + + + + return ( + + +
+
+ + table.getColumn("content")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + + + + + + + + {tags.map((tag)=>{ + return ( + {tag.emoji ?? '❓'}{tag.name} + ) + })} + + + + + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + Brak wyników. + + + )} + +
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} z {" "} + {table.getFilteredRowModel().rows.length} wierszy wybrano. +
+
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/notes.tsx b/apps/web/src/components/notes.tsx new file mode 100644 index 0000000..2516e89 --- /dev/null +++ b/apps/web/src/components/notes.tsx @@ -0,0 +1,52 @@ +// src/components/ThreadNotes.tsx +'use client'; + +import { useState } from 'react'; +import { updateThreadNote } from '~/server/actions/update-threadnote'; + +import { ScrollArea } from '../components/ui/scroll-area'; +import { Button } from './ui/button'; + +interface ThreadNotesProps { + id: bigint; + note: string; + onNoteUpdate: (id: bigint, newNote: string) => void; +} + +export const ThreadNote = ({ id, note, onNoteUpdate }: ThreadNotesProps) => { + const [noteInput, setNoteInput] = useState(note); + const [hasChanges, setHasChanges] = useState(false); + + const handleInputChange = (newValue: string) => { + setNoteInput(newValue); + setHasChanges(true); + }; + + const saveNoteHandler = async () => { + await updateThreadNote({ threadId: id.toString(), content: noteInput }); + onNoteUpdate(id, noteInput); + setHasChanges(false); + }; + + return ( + +