diff --git a/prisma/dbml/schema.dbml b/prisma/dbml/schema.dbml index d546b66..9abb82f 100644 --- a/prisma/dbml/schema.dbml +++ b/prisma/dbml/schema.dbml @@ -31,8 +31,8 @@ Table medias { bannerPromotion hero_banner [not null] logs media_logs [not null] episodes episodes [not null] - collections collections [not null] reviews movie_reviews [not null] + inCollections CollectionMedia [not null] } Table media_logs { @@ -369,7 +369,7 @@ Table user_logs { Table collections { id String [pk] name String [not null] - medias medias [not null] + slug String [not null] owner users [not null] ownerId String [not null] accessStatus AccessStatus [not null, default: 'private'] @@ -379,6 +379,24 @@ Table collections { deletedAt DateTime createdAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null] + media_saved CollectionMedia [not null] + + indexes { + (slug, ownerId) [unique] + } +} + +Table CollectionMedia { + id String [pk] + collection collections [not null] + collectionId String [not null] + media medias [not null] + mediaId String [not null] + savedAt DateTime [default: `now()`, not null] + + indexes { + (collectionId, mediaId) [unique] + } } Table watch_histories { @@ -557,9 +575,9 @@ Table MediaCharacters { mediasId String [ref: > medias.id] } -Table MediaCollections { - collectionsId String [ref: > collections.id] - mediasId String [ref: > medias.id] +Table CollectionMedia { + incollectionsId String [ref: > CollectionMedia.id] + media_savedId String [ref: > CollectionMedia.id] } Table UserFavoriteGenres { @@ -747,6 +765,10 @@ Ref: user_logs.sessionId > user_sessions.id Ref: collections.ownerId > users.id +Ref: CollectionMedia.collectionId > collections.id + +Ref: CollectionMedia.mediaId > medias.id + Ref: watch_histories.id > episodes.id Ref: watch_histories.userId > users.id diff --git a/prisma/migrations/20260327155457_add_collection_unique_by_owner_and_id/migration.sql b/prisma/migrations/20260327155457_add_collection_unique_by_owner_and_id/migration.sql new file mode 100644 index 0000000..5a06b55 --- /dev/null +++ b/prisma/migrations/20260327155457_add_collection_unique_by_owner_and_id/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to alter the column `name` on the `collections` table. The data in that column could be lost. The data in that column will be cast from `VarChar(255)` to `VarChar(115)`. + - A unique constraint covering the columns `[slug,ownerId]` on the table `collections` will be added. If there are existing duplicate values, this will fail. + - Added the required column `slug` to the `collections` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "collections" ADD COLUMN "slug" VARCHAR(115) NOT NULL, +ALTER COLUMN "name" SET DATA TYPE VARCHAR(115); + +-- CreateIndex +CREATE UNIQUE INDEX "collections_slug_ownerId_key" ON "collections"("slug", "ownerId"); diff --git a/prisma/migrations/20260329033709_create_table_pivot_for_media_collections/migration.sql b/prisma/migrations/20260329033709_create_table_pivot_for_media_collections/migration.sql new file mode 100644 index 0000000..7d79d3c --- /dev/null +++ b/prisma/migrations/20260329033709_create_table_pivot_for_media_collections/migration.sql @@ -0,0 +1,33 @@ +/* + Warnings: + + - You are about to drop the `_MediaCollections` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_MediaCollections" DROP CONSTRAINT "_MediaCollections_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_MediaCollections" DROP CONSTRAINT "_MediaCollections_B_fkey"; + +-- DropTable +DROP TABLE "_MediaCollections"; + +-- CreateTable +CREATE TABLE "CollectionMedia" ( + "id" UUID NOT NULL, + "collectionId" UUID NOT NULL, + "mediaId" UUID NOT NULL, + "savedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CollectionMedia_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CollectionMedia_collectionId_mediaId_key" ON "CollectionMedia"("collectionId", "mediaId"); + +-- AddForeignKey +ALTER TABLE "CollectionMedia" ADD CONSTRAINT "CollectionMedia_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "collections"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionMedia" ADD CONSTRAINT "CollectionMedia_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "medias"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f91c2c2..9ccae5b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,11 +49,11 @@ model Media { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - bannerPromotion HeroBanner[] @relation("MediaBannerPromotion") - logs MediaLog[] @relation("MediaLogs") - episodes Episode[] @relation("MediaEpisodes") - collections Collection[] @relation("MediaCollections") - reviews MediaReview[] @relation("MediaReviews") + bannerPromotion HeroBanner[] @relation("MediaBannerPromotion") + logs MediaLog[] @relation("MediaLogs") + episodes Episode[] @relation("MediaEpisodes") + reviews MediaReview[] @relation("MediaReviews") + inCollections CollectionMedia[] @relation("CollectionMedia") @@index([status, onDraft, deletedAt]) @@index([mediaType]) @@ -418,8 +418,8 @@ model UserLog { model Collection { id String @id @db.Uuid - name String @db.VarChar(255) - medias Media[] @relation("MediaCollections") + name String @db.VarChar(115) + slug String @db.VarChar(115) owner User @relation("UserCollections", fields: [ownerId], references: [id]) ownerId String @db.Uuid accessStatus AccessStatus @default(private) @@ -429,9 +429,22 @@ model Collection { deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + + media_saved CollectionMedia[] @relation("CollectionMedia") + @@unique([slug, ownerId]) @@map("collections") } +model CollectionMedia { + id String @id @db.Uuid + collection Collection @relation("CollectionMedia", fields: [collectionId], references: [id]) + collectionId String @db.Uuid + media Media @relation("CollectionMedia", fields: [mediaId], references: [id]) + mediaId String @db.Uuid + savedAt DateTime @default(now()) + @@unique([collectionId, mediaId]) +} + model WatchHistory { id String @id @db.Uuid episode Episode @relation("EpisodeWatchHistories", fields: [id], references: [id]) diff --git a/src/modules/collection/controllers/addItemToCollectionBySytem.controller.ts b/src/modules/collection/controllers/addItemToCollectionBySytem.controller.ts new file mode 100644 index 0000000..50c3bb2 --- /dev/null +++ b/src/modules/collection/controllers/addItemToCollectionBySytem.controller.ts @@ -0,0 +1,22 @@ +import { Context, Static } from "elysia"; +import { returnWriteResponse } from "../../../helpers/callback/httpResponse"; +import { addItemToCollectionBySytemSchema } from "../schemas/addItemToCollectionBySytem.schema"; +import { addItemToCollectionBySystemService } from "../services/addItemToCollectionBySystem.service"; +import { mainErrorHandler } from "../../../helpers/error/handler"; + +export const addItemToCollectionBySytemController = async (ctx: { + set: Context["set"]; + headers: Static; + body: Static; +}) => { + try { + const savedItem = await addItemToCollectionBySystemService({ + cookie: ctx.headers.cookie, + collectionName: ctx.body.name, + mediaId: ctx.body.itemId, + }); + return returnWriteResponse(ctx.set, 200, "Item added to collection successfully", savedItem); + } catch (error) { + return mainErrorHandler(ctx.set, error); + } +}; diff --git a/src/modules/collection/controllers/removeItemFromCollectionBySytem.controller.ts b/src/modules/collection/controllers/removeItemFromCollectionBySytem.controller.ts new file mode 100644 index 0000000..fccdbd8 --- /dev/null +++ b/src/modules/collection/controllers/removeItemFromCollectionBySytem.controller.ts @@ -0,0 +1,22 @@ +import { Context, Static } from "elysia"; +import { mainErrorHandler } from "../../../helpers/error/handler"; +import { returnWriteResponse } from "../../../helpers/callback/httpResponse"; +import { removeItemFromCollectionBySystemService } from "../services/removeItemFromCollectionBySystem.service"; +import { removeItemFromCollectionBySytemSchema } from "../schemas/removeItemFromCollectionBySytem.schema"; + +export const removeItemFromCollectionBySytemController = async (ctx: { + set: Context["set"]; + headers: Static; + body: Static; +}) => { + try { + const removedItem = await removeItemFromCollectionBySystemService({ + cookie: ctx.headers.cookie, + collectionName: ctx.body.name, + mediaId: ctx.body.itemId, + }); + return returnWriteResponse(ctx.set, 200, "Item removed from collection successfully", removedItem); + } catch (error) { + return mainErrorHandler(ctx.set, error); + } +}; diff --git a/src/modules/collection/index.ts b/src/modules/collection/index.ts new file mode 100644 index 0000000..4c768c5 --- /dev/null +++ b/src/modules/collection/index.ts @@ -0,0 +1,9 @@ +import Elysia from "elysia"; +import { addItemToCollectionBySytemController } from "./controllers/addItemToCollectionBySytem.controller"; +import { addItemToCollectionBySytemSchema } from "./schemas/addItemToCollectionBySytem.schema"; +import { removeItemFromCollectionBySytemController } from "./controllers/removeItemFromCollectionBySytem.controller"; +import { removeItemFromCollectionBySytemSchema } from "./schemas/removeItemFromCollectionBySytem.schema"; + +export const collectionModule = new Elysia({ prefix: "/collections", tags: ["Collections"] }) + .post("/sys", addItemToCollectionBySytemController, addItemToCollectionBySytemSchema) + .delete("/sys", removeItemFromCollectionBySytemController, removeItemFromCollectionBySytemSchema); diff --git a/src/modules/collection/repositories/deleteItemInUserCollectionBySystem.repository.ts b/src/modules/collection/repositories/deleteItemInUserCollectionBySystem.repository.ts new file mode 100644 index 0000000..7f20a79 --- /dev/null +++ b/src/modules/collection/repositories/deleteItemInUserCollectionBySystem.repository.ts @@ -0,0 +1,31 @@ +import slugify from "slugify"; +import { AppError } from "../../../helpers/error/instances/app"; +import { prisma } from "../../../utils/databases/prisma/connection"; + +export type DeleteUserCollectionBySystemPayload = { + userId: string; + collectionName: string; + itemId: string; +}; + +export const deleteItemInUserCollectionBySystemRepository = async (payload: DeleteUserCollectionBySystemPayload) => { + try { + return await prisma.collection.update({ + where: { + slug_ownerId: { + slug: slugify(payload.collectionName, { lower: true }), + ownerId: payload.userId, + }, + }, + data: { + media_saved: { + deleteMany: { + mediaId: payload.itemId, + }, + }, + }, + }); + } catch (error) { + throw new AppError(500, "Failed to remove item from collection", error); + } +}; diff --git a/src/modules/collection/repositories/upsertUserCollectionBySystem.repository.ts b/src/modules/collection/repositories/upsertUserCollectionBySystem.repository.ts new file mode 100644 index 0000000..22dec34 --- /dev/null +++ b/src/modules/collection/repositories/upsertUserCollectionBySystem.repository.ts @@ -0,0 +1,60 @@ +import slugify from "slugify"; +import { AppError } from "../../../helpers/error/instances/app"; +import { prisma } from "../../../utils/databases/prisma/connection"; +import { generateUUIDv7 } from "../../../helpers/databases/uuidv7"; +import { Prisma } from "@prisma/client"; + +export interface UpsertUserCollectionRepositoryPayload { + userId: string; + collectionName: string; + mediaConnectId: string; +} + +export const upsertUserCollectionBySystemRepository = async (payload: UpsertUserCollectionRepositoryPayload) => { + try { + return await prisma.collection.upsert({ + where: { + slug_ownerId: { + slug: slugify(payload.collectionName, { lower: true }), + ownerId: payload.userId, + }, + }, + update: { + media_saved: { + create: { + id: generateUUIDv7(), + media: { + connect: { + id: payload.mediaConnectId, + }, + }, + }, + }, + }, + create: { + id: generateUUIDv7(), + name: payload.collectionName, + slug: slugify(payload.collectionName, { lower: true }), + owner: { + connect: { + id: payload.userId, + }, + }, + media_saved: { + create: { + id: generateUUIDv7(), + media: { + connect: { + id: payload.mediaConnectId, + }, + }, + }, + }, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") + throw new AppError(400, "Media item is already in the collection"); + throw new AppError(500, "Failed to upsert user collection"); + } +}; diff --git a/src/modules/collection/schemas/addItemToCollectionBySytem.schema.ts b/src/modules/collection/schemas/addItemToCollectionBySytem.schema.ts new file mode 100644 index 0000000..35b4574 --- /dev/null +++ b/src/modules/collection/schemas/addItemToCollectionBySytem.schema.ts @@ -0,0 +1,33 @@ +import { t } from "elysia"; +import { AppRouteSchema } from "../../../helpers/types/AppRouteSchema"; + +export const addItemToCollectionBySytemSchema = { + headers: t.Object({ + cookie: t.String({ description: "Authentication token in cookie format, e.g., auth_token=your_jwt_token;" }), + }), + body: t.Object({ + name: t.String({ description: "Name of the collection to which the item will be added" }), + itemId: t.String({ description: "ID of the item to be added to the collection", examples: ["12345"] }), + }), + detail: { + summary: "Add an item to a collection", + description: "Adds a specified item to a collection identified by its name.", + responses: { + 200: { + description: "The item was successfully added to the collection.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + status: { type: "number", example: 200 }, + message: { type: "string", example: "Item added to collection successfully" }, + }, + }, + }, + }, + }, + }, + }, +} satisfies AppRouteSchema; diff --git a/src/modules/collection/schemas/removeItemFromCollectionBySytem.schema.ts b/src/modules/collection/schemas/removeItemFromCollectionBySytem.schema.ts new file mode 100644 index 0000000..54a4382 --- /dev/null +++ b/src/modules/collection/schemas/removeItemFromCollectionBySytem.schema.ts @@ -0,0 +1,33 @@ +import { t } from "elysia"; +import { AppRouteSchema } from "../../../helpers/types/AppRouteSchema"; + +export const removeItemFromCollectionBySytemSchema = { + headers: t.Object({ + cookie: t.String({ description: "Authentication token in cookie format, e.g., auth_token=your_jwt_token;" }), + }), + body: t.Object({ + name: t.String({ description: "Name of the collection to which the item will be added" }), + itemId: t.String({ description: "ID of the item to be added to the collection", examples: ["12345"] }), + }), + detail: { + summary: "Remove an item from a collection", + description: "Removes a specified item from a collection identified by its name.", + responses: { + 200: { + description: "The item was successfully removed from the collection.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + status: { type: "number", example: 200 }, + message: { type: "string", example: "Item removed from collection successfully" }, + }, + }, + }, + }, + }, + }, + }, +} satisfies AppRouteSchema; diff --git a/src/modules/collection/services/addItemToCollectionBySystem.service.ts b/src/modules/collection/services/addItemToCollectionBySystem.service.ts new file mode 100644 index 0000000..ca5f3ff --- /dev/null +++ b/src/modules/collection/services/addItemToCollectionBySystem.service.ts @@ -0,0 +1,24 @@ +import { parse } from "cookie"; +import { tokenValidationService } from "../../auth/services/http/tokenValidation.service"; +import { ErrorForwarder } from "../../../helpers/error/instances/forwarder"; +import { upsertUserCollectionBySystemRepository } from "../repositories/upsertUserCollectionBySystem.repository"; + +export type AddItemToCollectionPayload = { + cookie: string; + collectionName: string; + mediaId: string; +}; + +export const addItemToCollectionBySystemService = async (payload: AddItemToCollectionPayload) => { + try { + const { auth_token } = parse(payload.cookie); + const userData = await tokenValidationService(auth_token as string); + return await upsertUserCollectionBySystemRepository({ + userId: userData.user.id, + collectionName: payload.collectionName, + mediaConnectId: payload.mediaId, + }); + } catch (error) { + ErrorForwarder(error); + } +}; diff --git a/src/modules/collection/services/removeItemFromCollectionBySystem.service.ts b/src/modules/collection/services/removeItemFromCollectionBySystem.service.ts new file mode 100644 index 0000000..a3e200d --- /dev/null +++ b/src/modules/collection/services/removeItemFromCollectionBySystem.service.ts @@ -0,0 +1,24 @@ +import { parse } from "cookie"; +import { ErrorForwarder } from "../../../helpers/error/instances/forwarder"; +import { tokenValidationService } from "../../auth/services/http/tokenValidation.service"; +import { deleteItemInUserCollectionBySystemRepository } from "../repositories/deleteItemInUserCollectionBySystem.repository"; + +export type RemoveItemFromCollectionPayload = { + cookie: string; + collectionName: string; + mediaId: string; +}; + +export const removeItemFromCollectionBySystemService = async (payload: RemoveItemFromCollectionPayload) => { + try { + const { auth_token } = parse(payload.cookie); + const { user } = await tokenValidationService(auth_token as string); + return await deleteItemInUserCollectionBySystemRepository({ + userId: user.id, + collectionName: payload.collectionName, + itemId: payload.mediaId, + }); + } catch (error) { + ErrorForwarder(error); + } +};