Compare commits

..

17 Commits

Author SHA1 Message Date
cef6da16cb Merge pull request 'feat/collection' (#29) from feat/collection into main
All checks were successful
Sync to GitHub / sync (push) Successful in 7s
Reviewed-on: #29
2026-03-29 14:18:30 +07:00
412b501c80 🚨 fix: resolve linting type error
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 31s
2026-03-29 14:17:42 +07:00
68d834ae6b feat: add delete endpoint to collection
Some checks failed
Integration Tests / integration-tests (pull_request) Failing after 1m24s
2026-03-29 12:10:50 +07:00
86fe39f5b5 🚚 chore: move collection route module 2026-03-29 11:23:27 +07:00
73b22d7f2c feat: add collection module 2026-03-29 11:17:52 +07:00
f1e79945b0 🗃️ db: redesign collection schema with manual pivot table to media 2026-03-29 10:43:20 +07:00
3768ae4c26 🚧 wip: implement collection upsert logic 2026-03-28 21:59:44 +07:00
56c921e800 👔 feat: add collection module 2026-03-27 23:42:26 +07:00
e798338107 🗃️ db: update schema for new collection 2026-03-27 22:56:18 +07:00
dade012888 Merge pull request 'hotfix/payload-banner' (#28) from hotfix/payload-banner into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #28
2026-03-25 17:57:49 +07:00
4001aec6ef ♻️ refactor: refine payload before sending to frontend
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 47s
2026-03-25 17:55:48 +07:00
dd70f9f9d4 ♻️ refactor: restructure banner select payload 2026-03-25 17:49:16 +07:00
26909154ab Merge pull request 'prune-banner' (#27) from prune-banner into main
All checks were successful
Sync to GitHub / sync (push) Successful in 9s
Reviewed-on: #27
2026-03-25 12:52:55 +07:00
7f6b1373f4 💥 breaking: update endpoint to support new banner schema
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m42s
2026-03-25 12:39:23 +07:00
794a130562 feat: add cache flush module 2026-03-25 11:37:06 +07:00
6599fa8f79 🗃️ db: reset prisma migrations for updated banner structure 2026-03-17 16:41:29 +07:00
27b66e6d34 🗃️ db: update schema to match new banner logic 2026-03-16 22:45:13 +07:00
31 changed files with 499 additions and 196 deletions

View File

@ -28,10 +28,11 @@ Table medias {
deletedAt DateTime deletedAt DateTime
createdAt DateTime [default: `now()`, not null] createdAt DateTime [default: `now()`, not null]
updatedAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null]
bannerPromotion hero_banner [not null]
logs media_logs [not null] logs media_logs [not null]
episodes episodes [not null] episodes episodes [not null]
collections collections [not null]
reviews movie_reviews [not null] reviews movie_reviews [not null]
inCollections CollectionMedia [not null]
} }
Table media_logs { Table media_logs {
@ -368,7 +369,7 @@ Table user_logs {
Table collections { Table collections {
id String [pk] id String [pk]
name String [not null] name String [not null]
medias medias [not null] slug String [not null]
owner users [not null] owner users [not null]
ownerId String [not null] ownerId String [not null]
accessStatus AccessStatus [not null, default: 'private'] accessStatus AccessStatus [not null, default: 'private']
@ -378,6 +379,24 @@ Table collections {
deletedAt DateTime deletedAt DateTime
createdAt DateTime [default: `now()`, not null] createdAt DateTime [default: `now()`, not null]
updatedAt 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 { Table watch_histories {
@ -496,12 +515,8 @@ Table email_system_histories {
Table hero_banner { Table hero_banner {
id String [pk] id String [pk]
orderPriority Int [unique] orderPriority Int [unique]
isClickable Boolean [not null, default: false] mediaId String [not null]
title String media medias [not null]
tags String[] [not null]
description String
buttonContent String
buttonLink String
imageUrl String imageUrl String
startDate DateTime [not null] startDate DateTime [not null]
endDate DateTime [not null] endDate DateTime [not null]
@ -560,9 +575,9 @@ Table MediaCharacters {
mediasId String [ref: > medias.id] mediasId String [ref: > medias.id]
} }
Table MediaCollections { Table CollectionMedia {
collectionsId String [ref: > collections.id] incollectionsId String [ref: > CollectionMedia.id]
mediasId String [ref: > medias.id] media_savedId String [ref: > CollectionMedia.id]
} }
Table UserFavoriteGenres { Table UserFavoriteGenres {
@ -750,6 +765,10 @@ Ref: user_logs.sessionId > user_sessions.id
Ref: collections.ownerId > users.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.id > episodes.id
Ref: watch_histories.userId > users.id Ref: watch_histories.userId > users.id
@ -780,6 +799,8 @@ Ref: email_system_accounts.createdBy > users.id
Ref: email_system_histories.userRelated > users.id Ref: email_system_histories.userRelated > users.id
Ref: hero_banner.mediaId > medias.id
Ref: hero_banner.creatorId > users.id Ref: hero_banner.creatorId > users.id
Ref: system_notifications.createdBy > users.id Ref: system_notifications.createdBy > users.id

View File

@ -206,7 +206,8 @@ CREATE TABLE "videos" (
"id" UUID NOT NULL, "id" UUID NOT NULL,
"episodeId" UUID NOT NULL, "episodeId" UUID NOT NULL,
"serviceId" UUID NOT NULL, "serviceId" UUID NOT NULL,
"code" VARCHAR(255) NOT NULL, "videoCode" VARCHAR(255) NOT NULL,
"thumbnailCode" TEXT,
"pendingUpload" BOOLEAN NOT NULL DEFAULT true, "pendingUpload" BOOLEAN NOT NULL DEFAULT true,
"uploadedBy" UUID NOT NULL, "uploadedBy" UUID NOT NULL,
"deletedAt" TIMESTAMP(3), "deletedAt" TIMESTAMP(3),
@ -497,6 +498,26 @@ CREATE TABLE "email_system_histories" (
CONSTRAINT "email_system_histories_pkey" PRIMARY KEY ("id") CONSTRAINT "email_system_histories_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "hero_banner" (
"id" UUID NOT NULL,
"orderPriority" INTEGER,
"isClickable" BOOLEAN NOT NULL DEFAULT false,
"title" VARCHAR(225),
"tags" TEXT[],
"description" TEXT,
"buttonContent" VARCHAR(100),
"buttonLink" TEXT,
"imageUrl" TEXT,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"creatorId" UUID NOT NULL,
CONSTRAINT "hero_banner_pkey" PRIMARY KEY ("id")
);
-- CreateTable -- CreateTable
CREATE TABLE "system_preferences" ( CREATE TABLE "system_preferences" (
"id" UUID NOT NULL, "id" UUID NOT NULL,
@ -629,7 +650,7 @@ CREATE UNIQUE INDEX "lang_va_char_language_vaId_charId_key" ON "lang_va_char"("l
CREATE UNIQUE INDEX "episodes_mediaId_episode_key" ON "episodes"("mediaId", "episode"); CREATE UNIQUE INDEX "episodes_mediaId_episode_key" ON "episodes"("mediaId", "episode");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "videos_serviceId_code_key" ON "videos"("serviceId", "code"); CREATE UNIQUE INDEX "videos_serviceId_videoCode_key" ON "videos"("serviceId", "videoCode");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "video_services_name_key" ON "video_services"("name"); CREATE UNIQUE INDEX "video_services_name_key" ON "video_services"("name");
@ -664,6 +685,12 @@ CREATE UNIQUE INDEX "email_system_accounts_email_key" ON "email_system_accounts"
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "email_system_accounts_username_key" ON "email_system_accounts"("username"); CREATE UNIQUE INDEX "email_system_accounts_username_key" ON "email_system_accounts"("username");
-- CreateIndex
CREATE UNIQUE INDEX "hero_banner_orderPriority_key" ON "hero_banner"("orderPriority");
-- CreateIndex
CREATE UNIQUE INDEX "system_preferences_key_key" ON "system_preferences"("key");
-- CreateIndex -- CreateIndex
CREATE INDEX "_MediaStudios_B_index" ON "_MediaStudios"("B"); CREATE INDEX "_MediaStudios_B_index" ON "_MediaStudios"("B");
@ -820,6 +847,9 @@ ALTER TABLE "email_system_accounts" ADD CONSTRAINT "email_system_accounts_create
-- AddForeignKey -- AddForeignKey
ALTER TABLE "email_system_histories" ADD CONSTRAINT "email_system_histories_userRelated_fkey" FOREIGN KEY ("userRelated") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "email_system_histories" ADD CONSTRAINT "email_system_histories_userRelated_fkey" FOREIGN KEY ("userRelated") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hero_banner" ADD CONSTRAINT "hero_banner_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "system_notifications" ADD CONSTRAINT "system_notifications_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "system_notifications" ADD CONSTRAINT "system_notifications_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,17 +0,0 @@
/*
Warnings:
- You are about to drop the column `code` on the `videos` table. All the data in the column will be lost.
- A unique constraint covering the columns `[serviceId,videoCode]` on the table `videos` will be added. If there are existing duplicate values, this will fail.
- Added the required column `videoCode` to the `videos` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "videos_serviceId_code_key";
-- AlterTable
ALTER TABLE "videos" RENAME COLUMN "code" TO "videoCode";
-- CreateIndex
DROP INDEX IF EXISTS "videos_serviceId_code_key";
CREATE UNIQUE INDEX "videos_serviceId_videoCode_key" ON "videos"("serviceId", "videoCode");

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "videos" ADD COLUMN "thumbnailCode" TEXT;

View File

@ -1,20 +0,0 @@
-- CreateTable
CREATE TABLE "HeroBanner" (
"id" UUID NOT NULL,
"isClickable" BOOLEAN NOT NULL DEFAULT false,
"title" VARCHAR(225),
"description" TEXT,
"buttonContent" VARCHAR(100),
"buttonLink" TEXT,
"imageUrl" TEXT,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"creatorId" UUID NOT NULL,
CONSTRAINT "HeroBanner_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "HeroBanner" ADD CONSTRAINT "HeroBanner_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,32 +0,0 @@
/*
Warnings:
- You are about to drop the `HeroBanner` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "HeroBanner" DROP CONSTRAINT "HeroBanner_creatorId_fkey";
-- DropTable
DROP TABLE "HeroBanner";
-- CreateTable
CREATE TABLE "hero_banner" (
"id" UUID NOT NULL,
"isClickable" BOOLEAN NOT NULL DEFAULT false,
"title" VARCHAR(225),
"description" TEXT,
"buttonContent" VARCHAR(100),
"buttonLink" TEXT,
"imageUrl" TEXT,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"creatorId" UUID NOT NULL,
CONSTRAINT "hero_banner_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "hero_banner" ADD CONSTRAINT "hero_banner_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,11 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[order]` on the table `hero_banner` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "hero_banner" ADD COLUMN "order" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "hero_banner_order_key" ON "hero_banner"("order");

View File

@ -1,16 +0,0 @@
/*
Warnings:
- You are about to drop the column `order` on the `hero_banner` table. All the data in the column will be lost.
- A unique constraint covering the columns `[orderPriority]` on the table `hero_banner` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "hero_banner_order_key";
-- AlterTable
ALTER TABLE "hero_banner" DROP COLUMN "order",
ADD COLUMN "orderPriority" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "hero_banner_orderPriority_key" ON "hero_banner"("orderPriority");

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "hero_banner" ADD COLUMN "tags" TEXT[];

View File

@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[key]` on the table `system_preferences` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "system_preferences_key_key" ON "system_preferences"("key");

View File

@ -0,0 +1,23 @@
/*
Warnings:
- You are about to drop the column `buttonContent` on the `hero_banner` table. All the data in the column will be lost.
- You are about to drop the column `buttonLink` on the `hero_banner` table. All the data in the column will be lost.
- You are about to drop the column `description` on the `hero_banner` table. All the data in the column will be lost.
- You are about to drop the column `isClickable` on the `hero_banner` table. All the data in the column will be lost.
- You are about to drop the column `tags` on the `hero_banner` table. All the data in the column will be lost.
- You are about to drop the column `title` on the `hero_banner` table. All the data in the column will be lost.
- Added the required column `mediaId` to the `hero_banner` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "hero_banner" DROP COLUMN "buttonContent",
DROP COLUMN "buttonLink",
DROP COLUMN "description",
DROP COLUMN "isClickable",
DROP COLUMN "tags",
DROP COLUMN "title",
ADD COLUMN "mediaId" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "hero_banner" ADD CONSTRAINT "hero_banner_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "medias"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

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

View File

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

View File

@ -49,10 +49,11 @@ model Media {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
bannerPromotion HeroBanner[] @relation("MediaBannerPromotion")
logs MediaLog[] @relation("MediaLogs") logs MediaLog[] @relation("MediaLogs")
episodes Episode[] @relation("MediaEpisodes") episodes Episode[] @relation("MediaEpisodes")
collections Collection[] @relation("MediaCollections")
reviews MediaReview[] @relation("MediaReviews") reviews MediaReview[] @relation("MediaReviews")
inCollections CollectionMedia[] @relation("CollectionMedia")
@@index([status, onDraft, deletedAt]) @@index([status, onDraft, deletedAt])
@@index([mediaType]) @@index([mediaType])
@ -417,8 +418,8 @@ model UserLog {
model Collection { model Collection {
id String @id @db.Uuid id String @id @db.Uuid
name String @db.VarChar(255) name String @db.VarChar(115)
medias Media[] @relation("MediaCollections") slug String @db.VarChar(115)
owner User @relation("UserCollections", fields: [ownerId], references: [id]) owner User @relation("UserCollections", fields: [ownerId], references: [id])
ownerId String @db.Uuid ownerId String @db.Uuid
accessStatus AccessStatus @default(private) accessStatus AccessStatus @default(private)
@ -428,9 +429,22 @@ model Collection {
deletedAt DateTime? deletedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
media_saved CollectionMedia[] @relation("CollectionMedia")
@@unique([slug, ownerId])
@@map("collections") @@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 { model WatchHistory {
id String @id @db.Uuid id String @id @db.Uuid
episode Episode @relation("EpisodeWatchHistories", fields: [id], references: [id]) episode Episode @relation("EpisodeWatchHistories", fields: [id], references: [id])
@ -557,12 +571,8 @@ model EmailSystemHistory {
model HeroBanner { model HeroBanner {
id String @id @db.Uuid id String @id @db.Uuid
orderPriority Int? @unique orderPriority Int? @unique
isClickable Boolean @default(false) mediaId String @db.Uuid
title String? @db.VarChar(225) media Media @relation("MediaBannerPromotion", fields: [mediaId], references: [id])
tags String[]
description String? @db.Text
buttonContent String? @db.VarChar(100)
buttonLink String? @db.Text
imageUrl String? @db.Text imageUrl String? @db.Text
startDate DateTime startDate DateTime
endDate DateTime endDate DateTime

View File

@ -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<typeof addItemToCollectionBySytemSchema.headers>;
body: Static<typeof addItemToCollectionBySytemSchema.body>;
}) => {
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);
}
};

View File

@ -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<typeof removeItemFromCollectionBySytemSchema.headers>;
body: Static<typeof removeItemFromCollectionBySytemSchema.body>;
}) => {
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);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import { Context } from "elysia";
import { returnWriteResponse } from "../../../helpers/callback/httpResponse";
import { clearHeroBannerService } from "../services/clearHeroBanner.service";
export const clearHeroBannerController = async (ctx: { set: Context["set"] }) => {
const cacheCleared = await clearHeroBannerService();
return returnWriteResponse(ctx.set, 200, "Hero banner cache flushed successfully", cacheCleared);
};

View File

@ -0,0 +1,4 @@
import Elysia from "elysia";
import { clearHeroBannerController } from "./controllers/clearHeroBanner.controller";
export const flushCacheModule = new Elysia({ prefix: "/flush-cache" }).put("/hero-banner", clearHeroBannerController);

View File

@ -0,0 +1,12 @@
import { redisKey } from "../../../config/redis/key";
import { AppError } from "../../../helpers/error/instances/app";
import { redis } from "../../../utils/databases/redis/connection";
export const clearHeroBannerService = async () => {
try {
const cache = await redis.del(redisKey.find((key) => key.name === "HERO_BANNER")?.key || "");
return cache > 0; // Returns true if cache was cleared, false if it was not found
} catch (error) {
throw new AppError(500, "Failed to clear hero banner cache", error);
}
};

View File

@ -20,6 +20,25 @@ export const findAllActiveHeroBannerRepository = async () => {
startDate: "asc", startDate: "asc",
}, },
], ],
select: {
orderPriority: true,
imageUrl: true,
media: {
select: {
id: true,
title: true,
slug: true,
pictureLarge: true,
synopsis: true,
genres: {
select: {
slug: true,
name: true,
},
},
},
},
},
}); });
} catch (error) { } catch (error) {
throw new AppError(500, "Failed to fetch active hero banners", error); throw new AppError(500, "Failed to fetch active hero banners", error);

View File

@ -8,26 +8,31 @@ import { findAllActiveHeroBannerRepository } from "../repositories/GET/findAllAc
export const getActiveHeroBannerService = async () => { export const getActiveHeroBannerService = async () => {
try { try {
// Check if Hero Banner is enabled in system preferences // Check if Hero Banner is enabled in system preferences
const isHeroBannerEnabled = await findSystemPreferenceService( const isHeroBannerEnabled = await findSystemPreferenceService("HERO_BANNER_ENABLED", "boolean");
"HERO_BANNER_ENABLED", if (!isHeroBannerEnabled) throw new AppError(403, "Hero Banner is disabled");
"boolean",
);
if (!isHeroBannerEnabled)
throw new AppError(403, "Hero Banner is disabled");
// Try to get active banners from Redis cache // Try to get active banners from Redis cache
const cachedBanners = await redis.get( const cachedBanners = await redis.get(`${redisKey.filter((key) => key.name === "HERO_BANNER")[0].key}`);
`${redisKey.filter((key) => key.name === "HERO_BANNER")[0].key}`,
);
if (cachedBanners) return JSON.parse(cachedBanners); if (cachedBanners) return JSON.parse(cachedBanners);
// If not in cache, fetch from database and cache the result // If not in cache, fetch from database and cache the result
const activeBanners = await findAllActiveHeroBannerRepository(); const activeBanners = await findAllActiveHeroBannerRepository();
const constructedBanners = activeBanners.map((banner) => ({
id: banner.media.id,
title: banner.media.title,
slug: banner.media.slug,
imageUrl: banner.imageUrl || banner.media.pictureLarge,
synopsis: banner.media.synopsis,
genres: banner.media.genres.map((genre) => ({
slug: genre.slug,
name: genre.name,
})),
}));
await redis.set( await redis.set(
`${redisKey.filter((key) => key.name === "HERO_BANNER")[0].key}`, `${redisKey.filter((key) => key.name === "HERO_BANNER")[0].key}`,
JSON.stringify(activeBanners), JSON.stringify(constructedBanners),
); );
return activeBanners; return constructedBanners;
} catch (error) { } catch (error) {
ErrorForwarder(error); ErrorForwarder(error);
} }

View File

@ -1,12 +1,12 @@
import { Prisma } from "@prisma/client";
import { AppError } from "../../../helpers/error/instances/app"; import { AppError } from "../../../helpers/error/instances/app";
import { prisma } from "../../../utils/databases/prisma/connection"; import { prisma } from "../../../utils/databases/prisma/connection";
import { generateUUIDv7 } from "../../../helpers/databases/uuidv7"; import { generateUUIDv7 } from "../../../helpers/databases/uuidv7";
import { SystemAccountId } from "../../../config/account/system"; import { SystemAccountId } from "../../../config/account/system";
import { Static } from "elysia";
import { createHeroBannerSchema } from "../schemas/createHeroBanner.schema";
import { Prisma } from "@prisma/client";
export const insertHeroBannerRepository = async ( export const insertHeroBannerRepository = async (payload: Static<typeof createHeroBannerSchema.body>) => {
payload: Omit<Prisma.HeroBannerCreateInput, "id" | "createdBy">,
) => {
try { try {
return await prisma.heroBanner.create({ return await prisma.heroBanner.create({
data: { data: {
@ -16,6 +16,9 @@ export const insertHeroBannerRepository = async (
}, },
}); });
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new AppError(400, "A hero banner with the order priority already exists", error);
}
throw new AppError(500, "Failed to insert hero banner", error); throw new AppError(500, "Failed to insert hero banner", error);
} }
}; };

View File

@ -3,45 +3,18 @@ import { AppRouteSchema } from "../../../helpers/types/AppRouteSchema";
export const createHeroBannerSchema = { export const createHeroBannerSchema = {
body: t.Object({ body: t.Object({
isClickable: t.Optional( orderPriority: t.Optional(
t.Boolean({ t.Number({ description: "The priority order of the hero banner. Lower numbers indicate higher priority." }),
description: "Indicates whether the hero banner is clickable",
}),
),
title: t.Optional(
t.String({
description: "The title of the hero banner",
}),
),
tags: t.Array(t.String(), {
description: "An array of tags associated with the hero banner",
}),
description: t.Optional(
t.String({
description: "A brief description of the hero banner",
}),
),
buttonContent: t.Optional(
t.String({
description: "The text content of the button on the hero banner",
}),
),
buttonLink: t.Optional(
t.String({
description: "The URL that the button on the hero banner links to",
}),
), ),
mediaId: t.String({ description: "The ID of the media associated with the hero banner" }),
imageUrl: t.Optional( imageUrl: t.Optional(
t.String({ t.String({
description: "The URL of the image used in the hero banner", description:
"The URL of the image used in the hero banner. If not provided, a thumbnail image of the media will be used.",
}), }),
), ),
startDate: t.String({ startDate: t.Date({ description: "The start date for the hero banner in ISO 8601 format" }),
description: "The start date for the hero banner in ISO 8601 format", endDate: t.Date({ description: "The end date for the hero banner in ISO 8601 format" }),
}),
endDate: t.String({
description: "The end date for the hero banner in ISO 8601 format",
}),
}), }),
detail: { detail: {
summary: "Create a new hero banner", summary: "Create a new hero banner",
@ -64,17 +37,16 @@ export const createHeroBannerSchema = {
"The created hero banner object. This field is returned only if the environment is running in development mode.", "The created hero banner object. This field is returned only if the environment is running in development mode.",
properties: { properties: {
id: { type: "string", description: "The ID of the created hero banner" }, id: { type: "string", description: "The ID of the created hero banner" },
isClickable: { type: "boolean", description: "Indicates whether the hero banner is clickable" }, orderPriority: {
title: { type: "string", description: "The title of the hero banner" }, type: "number",
tags: { description: "The priority order of the hero banner. Lower numbers indicate higher priority.",
type: "array", },
items: { type: "string" }, mediaId: { type: "string", description: "The ID of the media associated with the hero banner" },
description: "An array of tags associated with the hero banner", imageUrl: {
type: "string",
description:
"The URL of the image used in the hero banner. If not provided, a thumbnail image of the media will be used.",
}, },
description: { type: "string", description: "A brief description of the hero banner" },
buttonContent: { type: "string", description: "The text content of the button on the hero banner" },
buttonLink: { type: "string", description: "The URL that the button on the hero banner links to" },
imageUrl: { type: "string", description: "The URL of the image used in the hero banner" },
startDate: { startDate: {
type: "string", type: "string",
format: "date-time", format: "date-time",

View File

@ -1,10 +1,9 @@
import { Static } from "elysia";
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder"; import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
import { CreateHeroBannerRequestBody } from "../../controllers/createHeroBanner.controller";
import { insertHeroBannerRepository } from "../../repositories/insertHeroBanner.repository"; import { insertHeroBannerRepository } from "../../repositories/insertHeroBanner.repository";
import { createHeroBannerSchema } from "../../schemas/createHeroBanner.schema";
export const createHeroBannerService = async ( export const createHeroBannerService = async (payload: Static<typeof createHeroBannerSchema.body>) => {
payload: CreateHeroBannerRequestBody,
) => {
try { try {
return await insertHeroBannerRepository(payload); return await insertHeroBannerRepository(payload);
} catch (error) { } catch (error) {