feat: replaced prisma to kysely for queries

This commit is contained in:
Hackntosh 2024-10-20 21:46:30 +01:00
parent bc40355fa2
commit 86b9f6ead8
20 changed files with 712 additions and 352 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -48,15 +48,19 @@
"dotenv-expand": "^11.0.6", "dotenv-expand": "^11.0.6",
"file-type": "16.5.4", "file-type": "16.5.4",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"kysely": "^0.27.4",
"nestjs-s3": "^2.0.1", "nestjs-s3": "^2.0.1",
"nestjs-zod": "^3.0.0", "nestjs-zod": "^3.0.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.13.0",
"prisma-kysely": "^1.8.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"tstl": "^3.0.0" "tstl": "^3.0.0",
"uuid": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.5.3", "@biomejs/biome": "1.5.3",
@ -72,7 +76,9 @@
"@types/node": "^20.16.11", "@types/node": "^20.16.11",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"jest": "^29.7.0", "jest": "^29.7.0",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",

View file

@ -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");

View file

@ -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");

View file

@ -1,5 +1,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-kysely"
output = "../src/db"
fileName = "types.ts"
} }
datasource db { datasource db {
@ -39,23 +41,25 @@ model Kweek {
} }
model KweekLike { model KweekLike {
id String @id @default(uuid())
kweekId String kweekId String
kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade) kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) 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. // I should join these two up? Yeah, but I will not do it since it didn't work on the first time.
model CommentLike { model CommentLike {
id String @id @default(uuid())
commentId String commentId String
comment Comments @relation(fields: [commentId], references: [id], onDelete: Cascade) comment Comments @relation(fields: [commentId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@id([commentId, userId])
} }
model Follows { model Follows {

View file

@ -1,7 +1,7 @@
import { FastifyMulterModule } from "@nest-lab/fastify-multer"; import { FastifyMulterModule } from "@nest-lab/fastify-multer";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Module } from "@nestjs/common"; 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 { APP_GUARD, APP_PIPE } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler"; import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler";
import { S3Module } from "nestjs-s3"; import { S3Module } from "nestjs-s3";
@ -10,6 +10,7 @@ import { AuthModule } from "./auth/auth.module";
import { JwtAuthGuard } from "./auth/jwt-auth.guard"; import { JwtAuthGuard } from "./auth/jwt-auth.guard";
import { Configuration } from "./configuration"; import { Configuration } from "./configuration";
import { KweeksModule } from "./kweeks/kweeks.module"; import { KweeksModule } from "./kweeks/kweeks.module";
import { KyselyModule } from "./services/kysely/kysely.module";
import { UserModule } from "./users/users.module"; import { UserModule } from "./users/users.module";
@Module({ @Module({
@ -19,6 +20,15 @@ import { UserModule } from "./users/users.module";
storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()), storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()),
errorMessage: "Too many requests", 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({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
}), }),

View file

@ -11,4 +11,9 @@ export namespace Configuration {
export const MINIO_DEFAULT_BUCKETS = () => export const MINIO_DEFAULT_BUCKETS = () =>
Environment.env.MINIO_DEFAULT_BUCKETS; Environment.env.MINIO_DEFAULT_BUCKETS;
export const MINIO_ENDPOINT = () => Environment.env.MINIO_ENDPOINT; 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;
} }

71
src/db/types.ts Normal file
View file

@ -0,0 +1,71 @@
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
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<Timestamp>;
};
export type Comments = {
id: Generated<string>;
content: string;
userId: string;
kweekId: string | null;
attachments: string[];
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
parentId: string | null;
};
export type Follows = {
followerId: string;
followingId: string;
};
export type Kweek = {
id: Generated<string>;
content: string;
authorId: string;
attachments: string[];
createdAt: Generated<Timestamp>;
updatedAt: Timestamp;
};
export type KweekLike = {
kweekId: string;
userId: string;
createdAt: Generated<Timestamp>;
};
export type Notifications = {
id: Generated<string>;
type: NotificationType;
content: string;
createdAt: Generated<Timestamp>;
fromUserId: string;
toUserId: string;
};
export type User = {
id: Generated<string>;
displayName: string | null;
username: string;
email: string;
password: string;
profileImage: string | null;
socketId: string | null;
createdAt: Generated<Timestamp>;
};
export type DB = {
CommentLike: CommentLike;
Comments: Comments;
Follows: Follows;
Kweek: Kweek;
KweekLike: KweekLike;
Notifications: Notifications;
User: User;
};

View file

@ -7,11 +7,15 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { PrismaService } from "src/services/prisma/prisma.service"; import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.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"; import { selectCommentsWithReplies } from "./schemas/prisma_queries.schema";
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
constructor( constructor(
private readonly commentsRepository: CommentsRepository,
private readonly kweeksRepository: KweeksRepository,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly s3: S3Service, private readonly s3: S3Service,
) {} ) {}
@ -29,33 +33,24 @@ export class CommentsService {
} }
// Verifies if the kweek_id is a kweek or a comment // Verifies if the kweek_id is a kweek or a comment
const parentComment = await this.prisma.comments.findUnique({ const parentComment = await this.commentsRepository.findOne(kweek_id);
where: { id: kweek_id },
});
let kweek = null; let kweek = undefined;
if (parentComment === null) { if (parentComment === undefined) {
kweek = await this.prisma.kweek.findFirst({ kweek = await this.kweeksRepository.findOne(kweek_id);
where: { id: kweek_id },
});
if (kweek === null) { if (kweek === undefined) {
throw new NotFoundException("Kweek/Comment not found"); throw new NotFoundException("Kweek/Comment not found");
} }
} }
const { id } = await this.prisma.comments.create({ const { id } = await this.commentsRepository.create(
data: { content,
content, user_id,
userId: user_id, kweek ? kweek.id : null,
kweekId: kweek ? kweek.id : null, parentComment ? parentComment.id : null,
parentId: parentComment ? parentComment.id : null, );
},
select: {
id: true,
},
});
let attachments = []; let attachments = [];
@ -63,41 +58,34 @@ export class CommentsService {
attachments = await this.s3.multiImageUpload(id, files); attachments = await this.s3.multiImageUpload(id, files);
} }
await this.prisma.comments.update({ await this.commentsRepository.addAttachments(id, attachments);
where: {
id,
},
data: {
attachments,
},
});
return await this.prisma.comments.findFirst({ where: { id } }); return await this.commentsRepository.findOne(id);
} }
async info(comment_id: string) { async info(comment_id: string) {
const comment = await this.prisma.comments.findUnique({ const comment = await this.commentsRepository.findOne(comment_id);
where: { id: comment_id },
select: {
...selectCommentsWithReplies,
},
});
if (comment === null) { if (comment === undefined) {
throw new NotFoundException("Comment not found"); 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) { 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({ if (comment === undefined) {
where: { id: comment_id },
});
if (comment === null) {
throw new NotFoundException("Comment not found"); throw new NotFoundException("Comment not found");
} }
@ -105,29 +93,16 @@ export class CommentsService {
throw new UnauthorizedException("Forbidden"); throw new UnauthorizedException("Forbidden");
} }
if (comment.content === content.trim()) { const new_content =
new_content = comment.content; comment.content === content.trim() ? comment.content : content;
}
return await this.prisma.comments.update({ return await this.commentsRepository.update(comment_id, new_content);
where: {
id: comment_id,
},
data: {
content: new_content,
},
select: {
...selectCommentsWithReplies,
},
});
} }
async delete(comment_id: string, user_id: string) { async delete(comment_id: string, user_id: string) {
const comment = await this.prisma.comments.findFirst({ const comment = await this.commentsRepository.findOne(comment_id);
where: { id: comment_id },
});
if (comment === null) { if (comment === undefined) {
throw new NotFoundException("Comment not found"); throw new NotFoundException("Comment not found");
} }
@ -137,49 +112,28 @@ export class CommentsService {
await this.s3.deleteFiles(comment.attachments); await this.s3.deleteFiles(comment.attachments);
await this.prisma.comments.delete({ await this.commentsRepository.delete(comment.id);
where: {
id: comment_id,
},
});
return {}; return {};
} }
async like(comment_id: string, user_id: string) { async like(comment_id: string, user_id: string) {
const comment = await this.prisma.comments.findFirst({ const comment = await this.commentsRepository.findOne(comment_id);
where: {
id: comment_id,
},
});
if (comment === null) { if (comment === undefined) {
throw new NotFoundException("Comment not found"); throw new NotFoundException("Comment not found");
} }
const is_comment_already_liked = await this.prisma.commentLike.findFirst({ const is_comment_already_liked =
where: { await this.commentsRepository.isAlreadyLiked(comment.id, user_id);
commentId: comment.id,
userId: user_id,
},
});
if (is_comment_already_liked !== null) { console.log(is_comment_already_liked);
await this.prisma.commentLike.deleteMany({
where: {
commentId: comment.id,
userId: user_id,
},
});
if (is_comment_already_liked) {
await this.commentsRepository.dislike(comment.id, user_id);
return {}; return {};
} }
return await this.prisma.commentLike.create({ return await this.commentsRepository.like(user_id, comment.id);
data: {
commentId: comment.id,
userId: user_id,
},
});
} }
} }

View file

@ -1,14 +1,24 @@
import { Module } from "@nestjs/common"; 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 { S3Service } from "src/services/s3/s3.service";
import { UsersRepository } from "src/users/repository/users.repository";
import { CommentsController } from "./comments.controller"; import { CommentsController } from "./comments.controller";
import { CommentsService } from "./comments.service"; import { CommentsService } from "./comments.service";
import { KweeksController } from "./kweeks.controller"; import { KweeksController } from "./kweeks.controller";
import { KweeksService } from "./kweeks.service"; import { KweeksService } from "./kweeks.service";
import { CommentsRepository } from "./repository/comments.repository";
import { KweeksRepository } from "./repository/kweeks.repository";
@Module({ @Module({
imports: [PrismaModule],
controllers: [KweeksController, CommentsController], controllers: [KweeksController, CommentsController],
providers: [KweeksService, S3Service, CommentsService], providers: [
PrismaService,
KweeksService,
S3Service,
CommentsService,
UsersRepository,
CommentsRepository,
KweeksRepository,
],
}) })
export class KweeksModule {} export class KweeksModule {}

View file

@ -5,14 +5,13 @@ import {
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { selectComments, selectUser } from "./schemas/prisma_queries.schema"; import { KweeksRepository } from "./repository/kweeks.repository";
@Injectable() @Injectable()
export class KweeksService { export class KweeksService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly kweekRepository: KweeksRepository,
private readonly s3: S3Service, private readonly s3: S3Service,
) {} ) {}
async create(content: string, authorId: string, files: Array<File>) { async create(content: string, authorId: string, files: Array<File>) {
@ -22,15 +21,7 @@ export class KweeksService {
); );
} }
const { id } = await this.prisma.kweek.create({ const { id } = await this.kweekRepository.create(content, authorId);
data: {
content,
authorId,
},
select: {
id: true,
},
});
let attachments = []; let attachments = [];
@ -38,46 +29,34 @@ export class KweeksService {
attachments = await this.s3.multiImageUpload(id, files); attachments = await this.s3.multiImageUpload(id, files);
} }
await this.prisma.kweek.update({ await this.kweekRepository.addAttachments(id, attachments);
where: {
id,
},
data: {
attachments,
},
});
return await this.prisma.kweek.findFirst({ where: { id } }); return await this.kweekRepository.findOne(id);
} }
async findOne(id: string) { async findOne(id: string) {
const post = await this.prisma.kweek.findFirst({ const post = await this.kweekRepository.findOne(id);
where: {
id,
},
include: {
author: selectUser,
_count: {
select: { comments: true, likes: true },
},
likes: true,
comments: selectComments,
},
});
if (post === null) { if (post === undefined) {
throw new NotFoundException("Post not found"); 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) { 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 === undefined) {
if (post === null) {
throw new NotFoundException("Post not found"); throw new NotFoundException("Post not found");
} }
@ -85,37 +64,16 @@ export class KweeksService {
throw new UnauthorizedException("Forbidden"); throw new UnauthorizedException("Forbidden");
} }
if (post.content === content.trim()) { const new_content =
new_content = post.content; post.content === content.trim() ? post.content : content;
}
return await this.prisma.kweek.update({ return await this.kweekRepository.update(post_id, new_content);
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,
},
},
},
});
} }
async remove(user_id: string, id: string) { 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"); throw new NotFoundException("Post not found");
} }
@ -125,49 +83,29 @@ export class KweeksService {
await this.s3.deleteFiles(post.attachments); await this.s3.deleteFiles(post.attachments);
await this.prisma.kweek.delete({ await this.kweekRepository.delete(id);
where: {
id,
},
});
return {}; return {};
} }
async like(user_id: string, kweek_id: string) { async like(user_id: string, kweek_id: string) {
const kweek = await this.prisma.kweek.findFirst({ const kweek = await this.kweekRepository.findOne(kweek_id);
where: {
id: kweek_id,
},
});
if (kweek === null) { if (kweek === undefined) {
throw new NotFoundException("Post not found"); throw new NotFoundException("Post not found");
} }
const is_kweek_already_liked = await this.prisma.kweekLike.findFirst({ const is_kweek_already_liked = await this.kweekRepository.isAlreadyLiked(
where: { kweek.id,
kweekId: kweek.id, user_id,
userId: user_id, );
},
});
if (is_kweek_already_liked !== null) { if (is_kweek_already_liked) {
await this.prisma.kweekLike.deleteMany({ await this.kweekRepository.dislike(kweek.id, user_id);
where: {
kweekId: kweek.id,
userId: user_id,
},
});
return {}; return {};
} }
return await this.prisma.kweekLike.create({ return await this.kweekRepository.like(user_id, kweek.id);
data: {
kweekId: kweek.id,
userId: user_id,
},
});
} }
} }

View file

@ -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<number>().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<number>().as("count"))
.executeTakeFirstOrThrow();
return count.count ?? 0;
}
}

View file

@ -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<number>().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<number>().as("count"))
.executeTakeFirstOrThrow();
return count.count ?? 0;
}
}

View file

@ -20,6 +20,8 @@ import { Configuration } from "./configuration";
TODO: Create the chat system. TODO: Create the chat system.
-> Initialize the websocket system first. -> Initialize the websocket system first.
TODO: Create a TOS. TODO: Create a TOS.
TODO: Improve Kysely Queries.
TODO: Fix Docker Image.
*/ */
async function bootstrap() { async function bootstrap() {

View file

@ -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<DatabaseOptions>()
.setClassMethodName("forRoot")
.build();

View file

@ -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 {}

View file

@ -0,0 +1,4 @@
import { Kysely } from "kysely";
import { DB } from "src/db/types";
export class Database extends Kysely<DB> {}

View file

@ -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<UserModel | undefined> {
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<UserModel | undefined> {
const user = await this.database
.selectFrom("User")
.select(["id", "displayName", "username", "createdAt"])
.where("id", "=", id)
.executeTakeFirst();
return user ?? undefined;
}
async findByUsername(username: string): Promise<UserModel | undefined> {
const user = await this.database
.selectFrom("User")
.select(["id", "displayName", "username", "createdAt"])
.where("username", "=", username)
.executeTakeFirst();
return user ?? undefined;
}
async findByEmail(email: string): Promise<UserModel | undefined> {
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<Pick<UserModel, "displayName" | "username" | "createdAt">> {
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<number> {
const count = await this.database
.selectFrom("Follows")
.where("followerId", "=", id)
.select(this.database.fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow();
return count.count ?? 0;
}
async countFollowing(id: string): Promise<number> {
const count = await this.database
.selectFrom("Follows")
.where("followingId", "=", id)
.select(this.database.fn.countAll<number>().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<void> {
await this.database
.updateTable("User")
.set({ email })
.where("id", "=", id)
.execute();
}
async updateUsername(
id: string,
username: string | undefined,
displayName: string | undefined,
): Promise<Pick<User, "username" | "displayName">> {
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<void> {
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<void> {
await this.database.deleteFrom("User").where("id", "=", id).execute();
}
async isFollowing(followerId: string, followingId: string): Promise<boolean> {
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();
}
}

View file

@ -1,13 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PrismaModule } from "src/services/prisma/prisma.module";
import { S3Service } from "src/services/s3/s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { UsersRepository } from "./repository/users.repository";
import { UserController } from "./users.controller"; import { UserController } from "./users.controller";
import { UserService } from "./users.service"; import { UserService } from "./users.service";
@Module({ @Module({
imports: [PrismaModule],
controllers: [UserController], controllers: [UserController],
providers: [UserService, S3Service], providers: [UserService, S3Service, UsersRepository],
exports: [UserService], exports: [UserService],
}) })
export class UserModule {} export class UserModule {}

View file

@ -5,76 +5,38 @@ import {
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import * as argon2 from "argon2"; import * as argon2 from "argon2";
import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { CreateUserDTO } from "./dto/create_user.dto"; import { CreateUserDTO } from "./dto/create_user.dto";
import { UserModel } from "./models/user.model"; import { UserModel } from "./models/user.model";
import { UsersRepository } from "./repository/users.repository";
import { User } from "./types/user.type"; import { User } from "./types/user.type";
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor( constructor(
private readonly prisma: PrismaService,
private readonly s3: S3Service, private readonly s3: S3Service,
private readonly userRepository: UsersRepository,
) {} ) {}
async auth_search(username: string): Promise<UserModel> { async auth_search(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({ return await this.userRepository.authSearch(username);
where: {
username,
},
select: {
id: true,
profileImage: true,
displayName: true,
username: true,
password: true,
},
});
if (user == null) {
return undefined;
}
return user;
} }
async info(username: string): Promise<UserModel> { async info(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({ const user = await this.userRepository.findByUsername(username);
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,
},
},
},
},
},
});
if (user === null) { if (user === undefined) {
throw new NotFoundException("User not found"); 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 { return {
...user, ...user,
followers: user.followers.length, followers,
following: user.following.length, following,
kweeks,
}; };
} }
@ -85,88 +47,56 @@ export class UserService {
}: CreateUserDTO): Promise< }: CreateUserDTO): Promise<
Pick<UserModel, "displayName" | "username" | "createdAt"> Pick<UserModel, "displayName" | "username" | "createdAt">
> { > {
if ((await this.prisma.user.findFirst({ where: { username } })) != null) { if ((await this.userRepository.findByUsername(username)) !== undefined) {
throw new BadRequestException("Username already in use"); 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"); throw new BadRequestException("Email already in use");
} }
// Password encryption
const hash = await argon2.hash(password); const hash = await argon2.hash(password);
const user = await this.prisma.user.create({ return await this.userRepository.create({
data: { username,
username, email,
email, password: hash,
password: hash,
},
select: {
displayName: true,
username: true,
createdAt: true,
},
}); });
return user;
} }
async follow(authenticated_id: string, username: string) { async follow(authenticated_id: string, username: string) {
const user_to_follow = await this.prisma.user.findFirst({ const user_to_follow = await this.userRepository.findByUsername(username);
where: { username },
});
if (user_to_follow === null) { if (user_to_follow === undefined) {
throw new NotFoundException("User to follow not found"); throw new NotFoundException("User to follow not found");
} }
const is_already_following = await this.prisma.follows.findFirst({ const is_already_following = await this.userRepository.isFollowing(
where: { user_to_follow.id,
followerId: user_to_follow.id, authenticated_id,
followingId: authenticated_id, );
},
});
if (is_already_following !== null) { if (is_already_following) {
await this.prisma.follows.deleteMany({ await this.userRepository.unfollow(user_to_follow.id, authenticated_id);
where: {
followerId: user_to_follow.id,
followingId: authenticated_id,
},
});
return {}; return {};
} }
return await this.prisma.follows.create({ return await this.userRepository.follow(
data: { user_to_follow.id,
followerId: user_to_follow.id, authenticated_id,
followingId: authenticated_id, );
},
});
} }
async updateEmail(id: string, email: string): Promise<{ message: string }> { async updateEmail(id: string, email: string): Promise<{ message: string }> {
const user = await this.prisma.user.findFirst({ const user = await this.userRepository.findById(id);
where: { id },
});
if (email !== undefined && email.trim() !== user.email) { if (email !== undefined && email.trim() !== user.email) {
const isAlreadyInUse = await this.prisma.user.findFirst({ const isAlreadyInUse = await this.userRepository.findByEmail(email);
where: { email }, if (isAlreadyInUse !== undefined && isAlreadyInUse.email !== user.email) {
});
if (isAlreadyInUse != null && isAlreadyInUse.email !== user.email) {
throw new BadRequestException("Email already in use"); throw new BadRequestException("Email already in use");
} }
await this.prisma.user.update({ await this.userRepository.updateEmail(id, email);
where: {
id,
},
data: {
email: email ?? user.email,
},
});
return { message: "Email updated successfully" }; return { message: "Email updated successfully" };
} }
@ -177,32 +107,19 @@ export class UserService {
username: string | undefined, username: string | undefined,
displayName: string, displayName: string,
): Promise<Pick<User, "username" | "displayName">> { ): Promise<Pick<User, "username" | "displayName">> {
const user = await this.prisma.user.findFirst({ const user = await this.userRepository.findById(id);
where: { id },
});
if (username !== undefined && username.trim() !== user.username) { if (username !== undefined && username.trim() !== user.username) {
const isAlreadyInUse = await this.prisma.user.findFirst({ const isAlreadyInUse = await this.userRepository.findByUsername(username);
where: { username }, if (
}); isAlreadyInUse !== undefined &&
if (isAlreadyInUse != null && isAlreadyInUse.username !== user.username) { isAlreadyInUse.username !== user.username
) {
throw new BadRequestException("Username already in use"); throw new BadRequestException("Username already in use");
} }
} }
return await this.prisma.user.update({ return await this.userRepository.updateUsername(id, username, displayName);
where: {
id,
},
data: {
displayName,
username: username ?? user.username,
},
select: {
displayName: true,
username: true,
},
});
} }
async updatePassword( async updatePassword(
@ -210,9 +127,7 @@ export class UserService {
old_password: string, old_password: string,
new_password: string, new_password: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const user = await this.prisma.user.findFirst({ const user = await this.userRepository.authSearch(id);
where: { id },
});
const validatePassword = await argon2.verify(user.password, old_password); const validatePassword = await argon2.verify(user.password, old_password);
@ -222,14 +137,7 @@ export class UserService {
const hash = await argon2.hash(new_password); const hash = await argon2.hash(new_password);
await this.prisma.user.update({ await this.userRepository.updatePassword(id, hash);
where: {
id,
},
data: {
password: hash,
},
});
return { message: "Password updated successfully" }; return { message: "Password updated successfully" };
} }
@ -237,27 +145,11 @@ export class UserService {
async uploadImage(id: string, image: File) { async uploadImage(id: string, image: File) {
const url = await this.s3.uploadImage(id, image.buffer); const url = await this.s3.uploadImage(id, image.buffer);
return await this.prisma.user.update({ return await this.userRepository.updateProfileImage(id, url);
where: {
id,
},
data: {
profileImage: url,
},
select: {
profileImage: true,
},
});
} }
async delete(id: string) { async delete(id: string) {
// TODO: Add validation for safety (like e-mail confirmation or password) await this.userRepository.delete(id);
// TODO: Delete the user's attachments when deleting, like Kweeks attachments and profile pictures. return { message: "User deleted" };
try {
await this.prisma.user.deleteMany({ where: { id } });
return { message: "User deleted" };
} catch (e) {
throw new BadRequestException("Error while trying to delete user");
}
} }
} }