diff --git a/bun.lockb b/bun.lockb index c1c18f3..598674e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index cc22cae..29a56aa 100644 --- a/package.json +++ b/package.json @@ -48,15 +48,19 @@ "dotenv-expand": "^11.0.6", "file-type": "16.5.4", "ioredis": "^5.4.1", + "kysely": "^0.27.4", "nestjs-s3": "^2.0.1", "nestjs-zod": "^3.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pg": "^8.13.0", + "prisma-kysely": "^1.8.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sharp": "^0.33.5", - "tstl": "^3.0.0" + "tstl": "^3.0.0", + "uuid": "^10.0.0" }, "devDependencies": { "@biomejs/biome": "1.5.3", @@ -72,7 +76,9 @@ "@types/node": "^20.16.11", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "husky": "^9.1.6", "jest": "^29.7.0", "lint-staged": "^15.2.10", diff --git a/prisma/migrations/20241020160756_removed_kweek_like_id/migration.sql b/prisma/migrations/20241020160756_removed_kweek_like_id/migration.sql new file mode 100644 index 0000000..4d2a5fb --- /dev/null +++ b/prisma/migrations/20241020160756_removed_kweek_like_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - The primary key for the `KweekLike` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `KweekLike` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "KweekLike" DROP CONSTRAINT "KweekLike_pkey", +DROP COLUMN "id", +ADD CONSTRAINT "KweekLike_pkey" PRIMARY KEY ("kweekId", "userId"); diff --git a/prisma/migrations/20241020204058_remove_comment_like_id/migration.sql b/prisma/migrations/20241020204058_remove_comment_like_id/migration.sql new file mode 100644 index 0000000..52da5d9 --- /dev/null +++ b/prisma/migrations/20241020204058_remove_comment_like_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - The primary key for the `CommentLike` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `CommentLike` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "CommentLike" DROP CONSTRAINT "CommentLike_pkey", +DROP COLUMN "id", +ADD CONSTRAINT "CommentLike_pkey" PRIMARY KEY ("commentId", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd2cec6..5a30317 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,7 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-kysely" + output = "../src/db" + fileName = "types.ts" } datasource db { @@ -39,23 +41,25 @@ model Kweek { } model KweekLike { - id String @id @default(uuid()) kweekId String kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + + @@id([kweekId, userId]) } // I should join these two up? Yeah, but I will not do it since it didn't work on the first time. model CommentLike { - id String @id @default(uuid()) commentId String comment Comments @relation(fields: [commentId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + + @@id([commentId, userId]) } model Follows { diff --git a/src/app.module.ts b/src/app.module.ts index ed1d73d..9b81055 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,7 @@ import { FastifyMulterModule } from "@nest-lab/fastify-multer"; import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; +import { ConfigModule, ConfigService } from "@nestjs/config"; import { APP_GUARD, APP_PIPE } from "@nestjs/core"; import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler"; import { S3Module } from "nestjs-s3"; @@ -10,6 +10,7 @@ import { AuthModule } from "./auth/auth.module"; import { JwtAuthGuard } from "./auth/jwt-auth.guard"; import { Configuration } from "./configuration"; import { KweeksModule } from "./kweeks/kweeks.module"; +import { KyselyModule } from "./services/kysely/kysely.module"; import { UserModule } from "./users/users.module"; @Module({ @@ -19,6 +20,15 @@ import { UserModule } from "./users/users.module"; storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()), errorMessage: "Too many requests", }), + KyselyModule.forRootAsync({ + useFactory: () => ({ + host: Configuration.POSTGRES_HOST(), + port: Number(Configuration.POSTGRES_PORT()), + user: Configuration.POSTGRES_USER(), + password: Configuration.POSTGRES_PASSWORD(), + database: Configuration.POSTGRES_DB(), + }), + }), ConfigModule.forRoot({ isGlobal: true, }), diff --git a/src/configuration.ts b/src/configuration.ts index f927127..085cdeb 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -11,4 +11,9 @@ export namespace Configuration { export const MINIO_DEFAULT_BUCKETS = () => Environment.env.MINIO_DEFAULT_BUCKETS; export const MINIO_ENDPOINT = () => Environment.env.MINIO_ENDPOINT; + export const POSTGRES_HOST = () => Environment.env.POSTGRES_HOST; + export const POSTGRES_PORT = () => Environment.env.POSTGRES_PORT; + export const POSTGRES_USER = () => Environment.env.POSTGRES_USER; + export const POSTGRES_PASSWORD = () => Environment.env.POSTGRES_PASSWORD; + export const POSTGRES_DB = () => Environment.env.POSTGRES_DB; } diff --git a/src/db/types.ts b/src/db/types.ts new file mode 100644 index 0000000..d99d3c1 --- /dev/null +++ b/src/db/types.ts @@ -0,0 +1,71 @@ +import type { ColumnType } from "kysely"; +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; +export type Timestamp = ColumnType; + +export const NotificationType = { + WARNING: "WARNING", + INFO: "INFO", +} as const; +export type NotificationType = + (typeof NotificationType)[keyof typeof NotificationType]; +export type CommentLike = { + commentId: string; + userId: string; + createdAt: Generated; +}; +export type Comments = { + id: Generated; + content: string; + userId: string; + kweekId: string | null; + attachments: string[]; + createdAt: Generated; + updatedAt: Generated; + parentId: string | null; +}; +export type Follows = { + followerId: string; + followingId: string; +}; +export type Kweek = { + id: Generated; + content: string; + authorId: string; + attachments: string[]; + createdAt: Generated; + updatedAt: Timestamp; +}; +export type KweekLike = { + kweekId: string; + userId: string; + createdAt: Generated; +}; +export type Notifications = { + id: Generated; + type: NotificationType; + content: string; + createdAt: Generated; + fromUserId: string; + toUserId: string; +}; +export type User = { + id: Generated; + displayName: string | null; + username: string; + email: string; + password: string; + profileImage: string | null; + socketId: string | null; + createdAt: Generated; +}; +export type DB = { + CommentLike: CommentLike; + Comments: Comments; + Follows: Follows; + Kweek: Kweek; + KweekLike: KweekLike; + Notifications: Notifications; + User: User; +}; diff --git a/src/kweeks/comments.service.ts b/src/kweeks/comments.service.ts index 6f049ba..11bb311 100644 --- a/src/kweeks/comments.service.ts +++ b/src/kweeks/comments.service.ts @@ -7,11 +7,15 @@ import { } from "@nestjs/common"; import { PrismaService } from "src/services/prisma/prisma.service"; import { S3Service } from "src/services/s3/s3.service"; +import { CommentsRepository } from "./repository/comments.repository"; +import { KweeksRepository } from "./repository/kweeks.repository"; import { selectCommentsWithReplies } from "./schemas/prisma_queries.schema"; @Injectable() export class CommentsService { constructor( + private readonly commentsRepository: CommentsRepository, + private readonly kweeksRepository: KweeksRepository, private readonly prisma: PrismaService, private readonly s3: S3Service, ) {} @@ -29,33 +33,24 @@ export class CommentsService { } // Verifies if the kweek_id is a kweek or a comment - const parentComment = await this.prisma.comments.findUnique({ - where: { id: kweek_id }, - }); + const parentComment = await this.commentsRepository.findOne(kweek_id); - let kweek = null; + let kweek = undefined; - if (parentComment === null) { - kweek = await this.prisma.kweek.findFirst({ - where: { id: kweek_id }, - }); + if (parentComment === undefined) { + kweek = await this.kweeksRepository.findOne(kweek_id); - if (kweek === null) { + if (kweek === undefined) { throw new NotFoundException("Kweek/Comment not found"); } } - const { id } = await this.prisma.comments.create({ - data: { - content, - userId: user_id, - kweekId: kweek ? kweek.id : null, - parentId: parentComment ? parentComment.id : null, - }, - select: { - id: true, - }, - }); + const { id } = await this.commentsRepository.create( + content, + user_id, + kweek ? kweek.id : null, + parentComment ? parentComment.id : null, + ); let attachments = []; @@ -63,41 +58,34 @@ export class CommentsService { attachments = await this.s3.multiImageUpload(id, files); } - await this.prisma.comments.update({ - where: { - id, - }, - data: { - attachments, - }, - }); + await this.commentsRepository.addAttachments(id, attachments); - return await this.prisma.comments.findFirst({ where: { id } }); + return await this.commentsRepository.findOne(id); } async info(comment_id: string) { - const comment = await this.prisma.comments.findUnique({ - where: { id: comment_id }, - select: { - ...selectCommentsWithReplies, - }, - }); + const comment = await this.commentsRepository.findOne(comment_id); - if (comment === null) { + if (comment === undefined) { throw new NotFoundException("Comment not found"); } - return comment; + const likes = await this.commentsRepository.countLikes(comment.id); + const comments = await this.commentsRepository.countComments(comment.id); + + return { + ...comment, + count: { + likes, + comments, + }, + }; } async update(comment_id: string, user_id: string, content: string) { - let new_content = content; + const comment = await this.commentsRepository.findOne(comment_id); - const comment = await this.prisma.comments.findFirst({ - where: { id: comment_id }, - }); - - if (comment === null) { + if (comment === undefined) { throw new NotFoundException("Comment not found"); } @@ -105,29 +93,16 @@ export class CommentsService { throw new UnauthorizedException("Forbidden"); } - if (comment.content === content.trim()) { - new_content = comment.content; - } + const new_content = + comment.content === content.trim() ? comment.content : content; - return await this.prisma.comments.update({ - where: { - id: comment_id, - }, - data: { - content: new_content, - }, - select: { - ...selectCommentsWithReplies, - }, - }); + return await this.commentsRepository.update(comment_id, new_content); } async delete(comment_id: string, user_id: string) { - const comment = await this.prisma.comments.findFirst({ - where: { id: comment_id }, - }); + const comment = await this.commentsRepository.findOne(comment_id); - if (comment === null) { + if (comment === undefined) { throw new NotFoundException("Comment not found"); } @@ -137,49 +112,28 @@ export class CommentsService { await this.s3.deleteFiles(comment.attachments); - await this.prisma.comments.delete({ - where: { - id: comment_id, - }, - }); + await this.commentsRepository.delete(comment.id); return {}; } async like(comment_id: string, user_id: string) { - const comment = await this.prisma.comments.findFirst({ - where: { - id: comment_id, - }, - }); + const comment = await this.commentsRepository.findOne(comment_id); - if (comment === null) { + if (comment === undefined) { throw new NotFoundException("Comment not found"); } - const is_comment_already_liked = await this.prisma.commentLike.findFirst({ - where: { - commentId: comment.id, - userId: user_id, - }, - }); + const is_comment_already_liked = + await this.commentsRepository.isAlreadyLiked(comment.id, user_id); - if (is_comment_already_liked !== null) { - await this.prisma.commentLike.deleteMany({ - where: { - commentId: comment.id, - userId: user_id, - }, - }); + console.log(is_comment_already_liked); + if (is_comment_already_liked) { + await this.commentsRepository.dislike(comment.id, user_id); return {}; } - return await this.prisma.commentLike.create({ - data: { - commentId: comment.id, - userId: user_id, - }, - }); + return await this.commentsRepository.like(user_id, comment.id); } } diff --git a/src/kweeks/kweeks.module.ts b/src/kweeks/kweeks.module.ts index b2fde9b..4e54662 100644 --- a/src/kweeks/kweeks.module.ts +++ b/src/kweeks/kweeks.module.ts @@ -1,14 +1,24 @@ import { Module } from "@nestjs/common"; -import { PrismaModule } from "src/services/prisma/prisma.module"; +import { PrismaService } from "src/services/prisma/prisma.service"; import { S3Service } from "src/services/s3/s3.service"; +import { UsersRepository } from "src/users/repository/users.repository"; import { CommentsController } from "./comments.controller"; import { CommentsService } from "./comments.service"; import { KweeksController } from "./kweeks.controller"; import { KweeksService } from "./kweeks.service"; +import { CommentsRepository } from "./repository/comments.repository"; +import { KweeksRepository } from "./repository/kweeks.repository"; @Module({ - imports: [PrismaModule], controllers: [KweeksController, CommentsController], - providers: [KweeksService, S3Service, CommentsService], + providers: [ + PrismaService, + KweeksService, + S3Service, + CommentsService, + UsersRepository, + CommentsRepository, + KweeksRepository, + ], }) export class KweeksModule {} diff --git a/src/kweeks/kweeks.service.ts b/src/kweeks/kweeks.service.ts index f4bcea8..87e7f47 100644 --- a/src/kweeks/kweeks.service.ts +++ b/src/kweeks/kweeks.service.ts @@ -5,14 +5,13 @@ import { NotFoundException, UnauthorizedException, } from "@nestjs/common"; -import { PrismaService } from "src/services/prisma/prisma.service"; import { S3Service } from "src/services/s3/s3.service"; -import { selectComments, selectUser } from "./schemas/prisma_queries.schema"; +import { KweeksRepository } from "./repository/kweeks.repository"; @Injectable() export class KweeksService { constructor( - private readonly prisma: PrismaService, + private readonly kweekRepository: KweeksRepository, private readonly s3: S3Service, ) {} async create(content: string, authorId: string, files: Array) { @@ -22,15 +21,7 @@ export class KweeksService { ); } - const { id } = await this.prisma.kweek.create({ - data: { - content, - authorId, - }, - select: { - id: true, - }, - }); + const { id } = await this.kweekRepository.create(content, authorId); let attachments = []; @@ -38,46 +29,34 @@ export class KweeksService { attachments = await this.s3.multiImageUpload(id, files); } - await this.prisma.kweek.update({ - where: { - id, - }, - data: { - attachments, - }, - }); + await this.kweekRepository.addAttachments(id, attachments); - return await this.prisma.kweek.findFirst({ where: { id } }); + return await this.kweekRepository.findOne(id); } async findOne(id: string) { - const post = await this.prisma.kweek.findFirst({ - where: { - id, - }, - include: { - author: selectUser, - _count: { - select: { comments: true, likes: true }, - }, - likes: true, - comments: selectComments, - }, - }); + const post = await this.kweekRepository.findOne(id); - if (post === null) { + if (post === undefined) { throw new NotFoundException("Post not found"); } - return post; + const likes = await this.kweekRepository.countLikes(post.id); + const comments = await this.kweekRepository.countComments(post.id); + + return { + ...post, + count: { + likes, + comments, + }, + }; } async update(user_id: string, post_id: string, content: string) { - let new_content = content; + const post = await this.kweekRepository.findOne(post_id); - const post = await this.prisma.kweek.findFirst({ where: { id: post_id } }); - - if (post === null) { + if (post === undefined) { throw new NotFoundException("Post not found"); } @@ -85,37 +64,16 @@ export class KweeksService { throw new UnauthorizedException("Forbidden"); } - if (post.content === content.trim()) { - new_content = post.content; - } + const new_content = + post.content === content.trim() ? post.content : content; - return await this.prisma.kweek.update({ - where: { - id: post_id, - }, - data: { - content: new_content, - }, - select: { - id: true, - content: true, - attachments: true, - createdAt: true, - updatedAt: true, - author: { - select: { - displayName: true, - username: true, - }, - }, - }, - }); + return await this.kweekRepository.update(post_id, new_content); } async remove(user_id: string, id: string) { - const post = await this.prisma.kweek.findFirst({ where: { id } }); + const post = await this.kweekRepository.findOne(id); - if (post === null) { + if (post === undefined) { throw new NotFoundException("Post not found"); } @@ -125,49 +83,29 @@ export class KweeksService { await this.s3.deleteFiles(post.attachments); - await this.prisma.kweek.delete({ - where: { - id, - }, - }); + await this.kweekRepository.delete(id); return {}; } async like(user_id: string, kweek_id: string) { - const kweek = await this.prisma.kweek.findFirst({ - where: { - id: kweek_id, - }, - }); + const kweek = await this.kweekRepository.findOne(kweek_id); - if (kweek === null) { + if (kweek === undefined) { throw new NotFoundException("Post not found"); } - const is_kweek_already_liked = await this.prisma.kweekLike.findFirst({ - where: { - kweekId: kweek.id, - userId: user_id, - }, - }); + const is_kweek_already_liked = await this.kweekRepository.isAlreadyLiked( + kweek.id, + user_id, + ); - if (is_kweek_already_liked !== null) { - await this.prisma.kweekLike.deleteMany({ - where: { - kweekId: kweek.id, - userId: user_id, - }, - }); + if (is_kweek_already_liked) { + await this.kweekRepository.dislike(kweek.id, user_id); return {}; } - return await this.prisma.kweekLike.create({ - data: { - kweekId: kweek.id, - userId: user_id, - }, - }); + return await this.kweekRepository.like(user_id, kweek.id); } } diff --git a/src/kweeks/repository/comments.repository.ts b/src/kweeks/repository/comments.repository.ts new file mode 100644 index 0000000..6cb6bd7 --- /dev/null +++ b/src/kweeks/repository/comments.repository.ts @@ -0,0 +1,117 @@ +import { Injectable } from "@nestjs/common"; +import { Database } from "src/services/kysely/kysely.service"; +import { v4 as uuid } from "uuid"; + +@Injectable() +export class CommentsRepository { + constructor(private readonly database: Database) {} + + async create( + content: string, + userId: string, + kweekId: string | null, + parentId: string | null, + ): Promise<{ id: string }> { + const [comment] = await this.database + .insertInto("Comments") + .values({ + id: uuid(), + content, + userId, + kweekId, + parentId, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(["id"]) + .execute(); + + return comment; + } + + async update(id: string, content: string) { + return await this.database + .updateTable("Comments") + .set({ content, updatedAt: new Date() }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + + async findOne(id: string) { + return await this.database + .selectFrom("Comments") + .select([ + "id", + "content", + "attachments", + "createdAt", + "userId", + "updatedAt", + "kweekId", + "parentId", + ]) + .where("id", "=", id) + .executeTakeFirst(); + } + + async delete(id: string) { + return await this.database + .deleteFrom("Comments") + .where("id", "=", id) + .execute(); + } + + async like(userId: string, commentId: string) { + return await this.database + .insertInto("CommentLike") + .values({ userId, commentId }) + .returningAll() + .execute(); + } + + async dislike(commentId: string, userId: string) { + await this.database + .deleteFrom("CommentLike") + .where("commentId", "=", commentId) + .where("userId", "=", userId) + .execute(); + } + + async isAlreadyLiked(commentId: string, userId: string) { + return await this.database + .selectFrom("CommentLike") + .where("commentId", "=", commentId) + .where("userId", "=", userId) + .executeTakeFirst(); + } + + async addAttachments(commentId: string, attachments: string[]) { + return await this.database + .updateTable("Comments") + .where("id", "=", commentId) + .set({ attachments }) + .returningAll() + .executeTakeFirst(); + } + + async countLikes(id: string) { + const count = await this.database + .selectFrom("CommentLike") + .where("commentId", "=", id) + .select(this.database.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + return count.count ?? 0; + } + + async countComments(id: string) { + const count = await this.database + .selectFrom("Comments") + .where((qb) => qb("kweekId", "=", id).or("parentId", "=", id)) + .select(this.database.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + return count.count ?? 0; + } +} diff --git a/src/kweeks/repository/kweeks.repository.ts b/src/kweeks/repository/kweeks.repository.ts new file mode 100644 index 0000000..31cd276 --- /dev/null +++ b/src/kweeks/repository/kweeks.repository.ts @@ -0,0 +1,99 @@ +import { Injectable } from "@nestjs/common"; +import { Database } from "src/services/kysely/kysely.service"; +import { v4 as uuid } from "uuid"; + +@Injectable() +export class KweeksRepository { + constructor(private readonly database: Database) {} + + async create(content: string, authorId: string): Promise<{ id: string }> { + const [kweek] = await this.database + .insertInto("Kweek") + .values({ + id: uuid(), + content, + authorId, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(["id"]) + .execute(); + + return kweek; + } + + async update(id: string, content: string) { + return await this.database + .updateTable("Kweek") + .set({ content, updatedAt: new Date() }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + + async findOne(id: string) { + return await this.database + .selectFrom("Kweek") + .selectAll() + .where("id", "=", id) + .executeTakeFirst(); + } + + async delete(id: string) { + return await this.database + .deleteFrom("Kweek") + .where("id", "=", id) + .execute(); + } + + async like(userId: string, kweekId: string) { + return await this.database + .insertInto("KweekLike") + .values({ userId, kweekId }) + .returningAll() + .execute(); + } + + async dislike(kweekId: string, userId: string) { + await this.database + .deleteFrom("KweekLike") + .where("kweekId", "=", kweekId) + .where("userId", "=", userId) + .execute(); + } + + async isAlreadyLiked(kweekId: string, userId: string) { + return await this.database + .selectFrom("KweekLike") + .where("kweekId", "=", kweekId) + .where("userId", "=", userId) + .executeTakeFirst(); + } + + async addAttachments(kweekId: string, attachments: string[]) { + return await this.database + .updateTable("Kweek") + .where("id", "=", kweekId) + .set({ attachments }) + .returningAll() + .executeTakeFirst(); + } + + async countLikes(id: string) { + const count = await this.database + .selectFrom("KweekLike") + .where("kweekId", "=", id) + .select(this.database.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + return count.count ?? 0; + } + + async countComments(id: string) { + const count = await this.database + .selectFrom("Comments") + .where("kweekId", "=", id) + .select(this.database.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + return count.count ?? 0; + } +} diff --git a/src/main.ts b/src/main.ts index 039c820..7549bed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,8 @@ import { Configuration } from "./configuration"; TODO: Create the chat system. -> Initialize the websocket system first. TODO: Create a TOS. + TODO: Improve Kysely Queries. + TODO: Fix Docker Image. */ async function bootstrap() { diff --git a/src/services/kysely/kysely.definition.ts b/src/services/kysely/kysely.definition.ts new file mode 100644 index 0000000..7baa7e8 --- /dev/null +++ b/src/services/kysely/kysely.definition.ts @@ -0,0 +1,16 @@ +import { ConfigurableModuleBuilder } from "@nestjs/common"; + +export interface DatabaseOptions { + host: string; + port: number; + user: string; + password: string; + database: string; +} + +export const { + ConfigurableModuleClass: ConfigurableDatabaseModule, + MODULE_OPTIONS_TOKEN: DATABASE_OPTIONS, +} = new ConfigurableModuleBuilder() + .setClassMethodName("forRoot") + .build(); diff --git a/src/services/kysely/kysely.module.ts b/src/services/kysely/kysely.module.ts new file mode 100644 index 0000000..9d9e89f --- /dev/null +++ b/src/services/kysely/kysely.module.ts @@ -0,0 +1,40 @@ +import { Global, Module } from "@nestjs/common"; +import { PostgresDialect } from "kysely"; +import { Pool } from "pg"; +import { + ConfigurableDatabaseModule, + DATABASE_OPTIONS, + DatabaseOptions, +} from "./kysely.definition"; +import { Database } from "./kysely.service"; + +@Global() +@Module({ + exports: [Database], + providers: [ + { + provide: Database, + inject: [DATABASE_OPTIONS], + useFactory: ({ + host, + port, + user, + password, + database, + }: DatabaseOptions) => { + const dialect = new PostgresDialect({ + pool: new Pool({ + host, + port, + user, + password, + database, + }), + }); + + return new Database({ dialect }); + }, + }, + ], +}) +export class KyselyModule extends ConfigurableDatabaseModule {} diff --git a/src/services/kysely/kysely.service.ts b/src/services/kysely/kysely.service.ts new file mode 100644 index 0000000..6b07cd1 --- /dev/null +++ b/src/services/kysely/kysely.service.ts @@ -0,0 +1,4 @@ +import { Kysely } from "kysely"; +import { DB } from "src/db/types"; + +export class Database extends Kysely {} diff --git a/src/users/repository/users.repository.ts b/src/users/repository/users.repository.ts new file mode 100644 index 0000000..fa4b75e --- /dev/null +++ b/src/users/repository/users.repository.ts @@ -0,0 +1,171 @@ +import { Injectable } from "@nestjs/common"; +import { Database } from "src/services/kysely/kysely.service"; +import { v4 as uuid } from "uuid"; +import { UserModel } from "../models/user.model"; +import { User } from "../types/user.type"; + +@Injectable() +export class UsersRepository { + constructor(private readonly database: Database) {} + + async authSearch(identifier: string): Promise { + const user = await this.database + .selectFrom("User") + .selectAll() + .where((eb) => + eb.or([eb("username", "=", identifier), eb("id", "=", identifier)]), + ) + .executeTakeFirst(); + + return user ?? undefined; + } + + async findById(id: string): Promise { + const user = await this.database + .selectFrom("User") + .select(["id", "displayName", "username", "createdAt"]) + .where("id", "=", id) + .executeTakeFirst(); + + return user ?? undefined; + } + + async findByUsername(username: string): Promise { + const user = await this.database + .selectFrom("User") + .select(["id", "displayName", "username", "createdAt"]) + .where("username", "=", username) + .executeTakeFirst(); + + return user ?? undefined; + } + + async findByEmail(email: string): Promise { + const user = await this.database + .selectFrom("User") + .select(["id", "displayName", "username", "createdAt"]) + .where("email", "=", email) + .executeTakeFirst(); + return user ?? undefined; + } + + async create(data: { + username: string; + email: string; + password: string; + }): Promise> { + const user = this.database + .insertInto("User") + .values({ + id: uuid(), + username: data.username, + email: data.email, + password: data.password, + createdAt: new Date(), + }) + .returning(["displayName", "username", "createdAt"]) + .executeTakeFirst(); + + return user; + } + + async countFollowers(id: string): Promise { + const count = await this.database + .selectFrom("Follows") + .where("followerId", "=", id) + .select(this.database.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + return count.count ?? 0; + } + + async countFollowing(id: string): Promise { + const count = await this.database + .selectFrom("Follows") + .where("followingId", "=", id) + .select(this.database.fn.countAll().as("count")) + .executeTakeFirstOrThrow(); + + return count.count ?? 0; + } + + async getUserKweeks(id: string) { + const kweeks = await this.database + .selectFrom("Kweek") + .where("authorId", "=", id) + .select(["id", "content", "attachments", "createdAt", "updatedAt"]) + .execute(); + return kweeks; + } + async updateEmail(id: string, email: string): Promise { + await this.database + .updateTable("User") + .set({ email }) + .where("id", "=", id) + .execute(); + } + + async updateUsername( + id: string, + username: string | undefined, + displayName: string | undefined, + ): Promise> { + const user = await this.database + .updateTable("User") + .set({ username, displayName }) + .where("id", "=", id) + .returning(["username", "displayName"]) + .executeTakeFirst(); + return user; + } + + async updatePassword(id: string, password: string): Promise { + await this.database + .updateTable("User") + .set({ password }) + .where("id", "=", id) + .execute(); + } + + async updateProfileImage( + id: string, + url: string, + ): Promise<{ profileImage: string }> { + return await this.database + .updateTable("User") + .set({ profileImage: url }) + .where("id", "=", id) + .returning(["profileImage"]) + .executeTakeFirst(); + } + + async delete(id: string): Promise { + await this.database.deleteFrom("User").where("id", "=", id).execute(); + } + + async isFollowing(followerId: string, followingId: string): Promise { + const follows = await this.database + .selectFrom("Follows") + .where("followerId", "=", followerId) + .where("followingId", "=", followingId) + .executeTakeFirst(); + + return follows !== undefined; + } + + async follow(followerId: string, followingId: string) { + return await this.database + .insertInto("Follows") + .values({ followerId, followingId }) + .returning(["followingId", "followerId"]) + .executeTakeFirst(); + } + + async unfollow(followerId: string, followingId) { + return await this.database + .deleteFrom("Follows") + .where("followerId", "=", followerId) + .where("followingId", "=", followingId) + .execute(); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 8dac932..fb538d2 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,13 +1,12 @@ import { Module } from "@nestjs/common"; -import { PrismaModule } from "src/services/prisma/prisma.module"; import { S3Service } from "src/services/s3/s3.service"; +import { UsersRepository } from "./repository/users.repository"; import { UserController } from "./users.controller"; import { UserService } from "./users.service"; @Module({ - imports: [PrismaModule], controllers: [UserController], - providers: [UserService, S3Service], + providers: [UserService, S3Service, UsersRepository], exports: [UserService], }) export class UserModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 2f2a78d..08efde3 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -5,76 +5,38 @@ import { NotFoundException, } from "@nestjs/common"; import * as argon2 from "argon2"; -import { PrismaService } from "src/services/prisma/prisma.service"; import { S3Service } from "src/services/s3/s3.service"; import { CreateUserDTO } from "./dto/create_user.dto"; import { UserModel } from "./models/user.model"; +import { UsersRepository } from "./repository/users.repository"; import { User } from "./types/user.type"; @Injectable() export class UserService { constructor( - private readonly prisma: PrismaService, private readonly s3: S3Service, + private readonly userRepository: UsersRepository, ) {} async auth_search(username: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - username, - }, - select: { - id: true, - profileImage: true, - displayName: true, - username: true, - password: true, - }, - }); - - if (user == null) { - return undefined; - } - - return user; + return await this.userRepository.authSearch(username); } async info(username: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { username }, - select: { - id: true, - profileImage: true, - displayName: true, - username: true, - createdAt: true, - followers: true, - following: true, - kweeks: { - select: { - id: true, - content: true, - attachments: true, - createdAt: true, - updatedAt: true, - _count: { - select: { - comments: true, - likes: true, - }, - }, - }, - }, - }, - }); + const user = await this.userRepository.findByUsername(username); - if (user === null) { + if (user === undefined) { throw new NotFoundException("User not found"); } + const followers = await this.userRepository.countFollowers(user.id); + const following = await this.userRepository.countFollowing(user.id); + const kweeks = await this.userRepository.getUserKweeks(user.id); + return { ...user, - followers: user.followers.length, - following: user.following.length, + followers, + following, + kweeks, }; } @@ -85,88 +47,56 @@ export class UserService { }: CreateUserDTO): Promise< Pick > { - if ((await this.prisma.user.findFirst({ where: { username } })) != null) { + if ((await this.userRepository.findByUsername(username)) !== undefined) { throw new BadRequestException("Username already in use"); } - if ((await this.prisma.user.findFirst({ where: { email } })) != null) { + if ((await this.userRepository.findByEmail(email)) !== undefined) { throw new BadRequestException("Email already in use"); } - // Password encryption const hash = await argon2.hash(password); - const user = await this.prisma.user.create({ - data: { - username, - email, - password: hash, - }, - select: { - displayName: true, - username: true, - createdAt: true, - }, + return await this.userRepository.create({ + username, + email, + password: hash, }); - - return user; } async follow(authenticated_id: string, username: string) { - const user_to_follow = await this.prisma.user.findFirst({ - where: { username }, - }); + const user_to_follow = await this.userRepository.findByUsername(username); - if (user_to_follow === null) { + if (user_to_follow === undefined) { throw new NotFoundException("User to follow not found"); } - const is_already_following = await this.prisma.follows.findFirst({ - where: { - followerId: user_to_follow.id, - followingId: authenticated_id, - }, - }); + const is_already_following = await this.userRepository.isFollowing( + user_to_follow.id, + authenticated_id, + ); - if (is_already_following !== null) { - await this.prisma.follows.deleteMany({ - where: { - followerId: user_to_follow.id, - followingId: authenticated_id, - }, - }); + if (is_already_following) { + await this.userRepository.unfollow(user_to_follow.id, authenticated_id); return {}; } - return await this.prisma.follows.create({ - data: { - followerId: user_to_follow.id, - followingId: authenticated_id, - }, - }); + return await this.userRepository.follow( + user_to_follow.id, + authenticated_id, + ); } async updateEmail(id: string, email: string): Promise<{ message: string }> { - const user = await this.prisma.user.findFirst({ - where: { id }, - }); + const user = await this.userRepository.findById(id); if (email !== undefined && email.trim() !== user.email) { - const isAlreadyInUse = await this.prisma.user.findFirst({ - where: { email }, - }); - if (isAlreadyInUse != null && isAlreadyInUse.email !== user.email) { + const isAlreadyInUse = await this.userRepository.findByEmail(email); + if (isAlreadyInUse !== undefined && isAlreadyInUse.email !== user.email) { throw new BadRequestException("Email already in use"); } - await this.prisma.user.update({ - where: { - id, - }, - data: { - email: email ?? user.email, - }, - }); + await this.userRepository.updateEmail(id, email); return { message: "Email updated successfully" }; } @@ -177,32 +107,19 @@ export class UserService { username: string | undefined, displayName: string, ): Promise> { - const user = await this.prisma.user.findFirst({ - where: { id }, - }); + const user = await this.userRepository.findById(id); if (username !== undefined && username.trim() !== user.username) { - const isAlreadyInUse = await this.prisma.user.findFirst({ - where: { username }, - }); - if (isAlreadyInUse != null && isAlreadyInUse.username !== user.username) { + const isAlreadyInUse = await this.userRepository.findByUsername(username); + if ( + isAlreadyInUse !== undefined && + isAlreadyInUse.username !== user.username + ) { throw new BadRequestException("Username already in use"); } } - return await this.prisma.user.update({ - where: { - id, - }, - data: { - displayName, - username: username ?? user.username, - }, - select: { - displayName: true, - username: true, - }, - }); + return await this.userRepository.updateUsername(id, username, displayName); } async updatePassword( @@ -210,9 +127,7 @@ export class UserService { old_password: string, new_password: string, ): Promise<{ message: string }> { - const user = await this.prisma.user.findFirst({ - where: { id }, - }); + const user = await this.userRepository.authSearch(id); const validatePassword = await argon2.verify(user.password, old_password); @@ -222,14 +137,7 @@ export class UserService { const hash = await argon2.hash(new_password); - await this.prisma.user.update({ - where: { - id, - }, - data: { - password: hash, - }, - }); + await this.userRepository.updatePassword(id, hash); return { message: "Password updated successfully" }; } @@ -237,27 +145,11 @@ export class UserService { async uploadImage(id: string, image: File) { const url = await this.s3.uploadImage(id, image.buffer); - return await this.prisma.user.update({ - where: { - id, - }, - data: { - profileImage: url, - }, - select: { - profileImage: true, - }, - }); + return await this.userRepository.updateProfileImage(id, url); } async delete(id: string) { - // TODO: Add validation for safety (like e-mail confirmation or password) - // TODO: Delete the user's attachments when deleting, like Kweeks attachments and profile pictures. - try { - await this.prisma.user.deleteMany({ where: { id } }); - return { message: "User deleted" }; - } catch (e) { - throw new BadRequestException("Error while trying to delete user"); - } + await this.userRepository.delete(id); + return { message: "User deleted" }; } }