Compare commits
10 Commits
10a19a066d
...
ae508ded6d
| Author | SHA1 | Date | |
|---|---|---|---|
| ae508ded6d | |||
| fd8f980d9a | |||
| 68fec64efc | |||
| 5a43769f69 | |||
| 6fff049c18 | |||
| 11a607b4da | |||
| ab0c8afca4 | |||
| 0521c27834 | |||
| ce56e13f30 | |||
| 4e8eda081c |
0
.github/workflows/ci.yaml
vendored
Normal file
0
.github/workflows/ci.yaml
vendored
Normal file
@ -5,7 +5,7 @@ import { defineConfig } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ["src/modules/debug/**"],
|
||||
ignores: ["src/modules/debug/**", "src/helpers/characters/generateSlug.ts"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{js,mjs,cjs,ts,mts,cts}"],
|
||||
|
||||
@ -181,6 +181,10 @@ Table videos {
|
||||
deletedAt DateTime
|
||||
createdAt DateTime [default: `now()`, not null]
|
||||
updatedAt DateTime [default: `now()`, not null]
|
||||
|
||||
indexes {
|
||||
(serviceId, code) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
Table video_services {
|
||||
@ -191,6 +195,7 @@ Table video_services {
|
||||
hexColor String [not null]
|
||||
endpointVideo String [not null]
|
||||
endpointThumbnail String
|
||||
endpointDownload String
|
||||
creator users [not null]
|
||||
createdBy String [not null]
|
||||
deletedAt DateTime
|
||||
|
||||
@ -218,6 +218,8 @@ model Video {
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([serviceId, code])
|
||||
@@map("videos")
|
||||
}
|
||||
|
||||
@ -229,6 +231,7 @@ model VideoService {
|
||||
hexColor String @db.VarChar(10)
|
||||
endpointVideo String @db.Text
|
||||
endpointThumbnail String? @db.Text
|
||||
endpointDownload String?
|
||||
creator User @relation("UserVideoServices", fields: [createdBy], references: [id])
|
||||
createdBy String @db.Uuid
|
||||
deletedAt DateTime?
|
||||
|
||||
@ -7,7 +7,7 @@ async function main() {
|
||||
console.log("🔌 Connecting to database...");
|
||||
|
||||
const userSystemSeedResult = await userSystemSeed();
|
||||
const userRoleSeedResult = await userRoleSeed(userSystemSeedResult.id);
|
||||
await userRoleSeed(userSystemSeedResult.id);
|
||||
|
||||
console.log("🌳 All seeds completed");
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ try {
|
||||
|
||||
// Extract the key and the remainder after "="
|
||||
const key = line.substring(0, delimiterIndex).trim();
|
||||
let remainder = line.substring(delimiterIndex + 1);
|
||||
const remainder = line.substring(delimiterIndex + 1);
|
||||
|
||||
// Attempt to separate value and inline comment (if any)
|
||||
let value = remainder;
|
||||
|
||||
@ -14,7 +14,7 @@ for (const remote of remotes) {
|
||||
try {
|
||||
execSync(`git push ${remote} main`, { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to push to ${remote}`);
|
||||
console.error(`❌ Failed to push to ${remote}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,17 +14,13 @@ export async function generateSlug(
|
||||
const baseSlug = slugify(input, { lower: true, strict: true });
|
||||
let uniqueSlug = baseSlug;
|
||||
|
||||
// CASE 1: Tidak ada config → langsung return slug
|
||||
if (!config) return uniqueSlug;
|
||||
|
||||
const { model, target } = config;
|
||||
|
||||
// CASE 2: Validasi pasangan model-target
|
||||
if (!model || !target) {
|
||||
throw new Error(`Both "model" and "target" must be provided together.`);
|
||||
}
|
||||
|
||||
// CASE 3: Cek unique
|
||||
const prismaModel = (prisma as any)[model];
|
||||
if (!prismaModel) {
|
||||
throw new Error(`Model "${model as string}" not found in PrismaClient.`);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { generateUUIDv7 } from "./uuidv7";
|
||||
|
||||
function createManyWithUUID<T extends { id?: string }>(items: T[]): T[] {
|
||||
export const createManyWithUUID = <T extends { id?: string }>(
|
||||
items: T[],
|
||||
): T[] => {
|
||||
return items.map((i) => ({
|
||||
...i,
|
||||
id: i.id ?? generateUUIDv7(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
|
||||
import { middleware } from "./middleware";
|
||||
import { validateEnv } from "./utils/startups/validateEnv";
|
||||
|
||||
@ -12,7 +14,7 @@ async function bootstrap() {
|
||||
sentryInit();
|
||||
|
||||
console.log("\x1b[1m\x1b[33m🚀 Starting backend services...\x1b[0m");
|
||||
const app = new Elysia()
|
||||
new Elysia()
|
||||
.use(middleware)
|
||||
.use(routes)
|
||||
.listen(process.env.APP_PORT || 3000);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import Elysia, { Context } from "elysia";
|
||||
import Elysia from "elysia";
|
||||
import { returnErrorResponse } from "../../helpers/callback/httpResponse";
|
||||
|
||||
export const appAccessTokenMiddleware = () =>
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import { Context } from "elysia";
|
||||
|
||||
export const isAdminMiddleware = (ctx: Context) => {
|
||||
//validate here
|
||||
const isAdmin = ctx.headers["isAdmin"];
|
||||
if (!isAdmin) {
|
||||
ctx.set.status = 403;
|
||||
return {
|
||||
error: "Forbidden",
|
||||
message: "You don't have access to this resource",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { AppError } from "../../../../helpers/error/instances/app";
|
||||
import { jwtDecode } from "../../../../helpers/http/jwt/decode";
|
||||
import { jwtEncode } from "../../../../helpers/http/jwt/encode";
|
||||
|
||||
export const tokenValidationService = (payload: string) => {
|
||||
try {
|
||||
|
||||
@ -3,7 +3,46 @@ import { mainErrorHandler } from "../../../helpers/error/handler";
|
||||
import { bulkInsertEpisodeService } from "../services/http/bulkInsertEpisode.service";
|
||||
import { returnWriteResponse } from "../../../helpers/callback/httpResponse";
|
||||
|
||||
// add pagination query
|
||||
/**
|
||||
* @function bulkInsertMediaController
|
||||
* @description Perform bulk insert of episodes for a specific media. This operation fetches episode data from external sources and inserts them into the database. The page parameter is optional; if not provided, the first page of episodes will be fetched.
|
||||
*
|
||||
* @param {Context & { body: { media_mal_id: number }; query: { page?: number } }} ctx
|
||||
* The context object containing the request body.
|
||||
* The body must include:
|
||||
* - media_mal_id: number - The MyAnimeList ID of the media for which episodes will be inserted.
|
||||
* The query may include:
|
||||
* - page?: number - (Optional) The page number of episodes to fetch and insert. If not provided, defaults to the first page.
|
||||
*
|
||||
* @example
|
||||
* Request route: POST /internal/episode/bulk-insert
|
||||
* Request body:
|
||||
* {
|
||||
* "media_mal_id": 12345
|
||||
* }
|
||||
* Query parameter:
|
||||
* ?page=2 (Optional, specifies the page number of episodes to fetch and insert)
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
* A response object indicating success or failure.
|
||||
* Return example:
|
||||
* {
|
||||
* success: true,
|
||||
* status: 201,
|
||||
* message: "Bulk insert episode operation completed successfully",
|
||||
* data: { ...bulkInsertResult } // Data returned only if the env run on development mode
|
||||
* }
|
||||
*
|
||||
* @throws {Object}
|
||||
* An error response object if validation fails or an error occurs during bulk insert operation.
|
||||
* Return example:
|
||||
* {
|
||||
* success: false,
|
||||
* status: <Status Code>,
|
||||
* message: "<Error Message>",
|
||||
* error: { ...errorDetails } // Additional error details if available and the env run on development mode
|
||||
* }
|
||||
*/
|
||||
export const bulkInsertEpisodeController = async (
|
||||
ctx: Context & { body: { media_mal_id: number }; query: { page?: number } },
|
||||
) => {
|
||||
@ -15,7 +54,7 @@ export const bulkInsertEpisodeController = async (
|
||||
return returnWriteResponse(
|
||||
ctx.set,
|
||||
201,
|
||||
"Success bulk insert for episode",
|
||||
"Bulk insert episode operation completed successfully",
|
||||
bulkInsertResult,
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@ -4,7 +4,7 @@ import { bulkInsertAnimeService } from "../services/http/bulkInsertAnime.service
|
||||
import { returnWriteResponse } from "../../../helpers/callback/httpResponse";
|
||||
|
||||
/**
|
||||
* @function bulkInsertAnimeController
|
||||
* @function bulkInsertMediaController
|
||||
* @description Insert new anime to the database only with mal_id. This operation including inserting related data such as genres, studios, producers, licensors, themes, demographics, and relations.
|
||||
*
|
||||
* @param {Context & { body: { mal_id: number } }} ctx
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import { Context } from "elysia";
|
||||
import { mainErrorHandler } from "../../../helpers/error/handler";
|
||||
import { bulkInsertVideoService } from "../services/http/bulkInsertVideo.service";
|
||||
import { returnWriteResponse } from "../../../helpers/callback/httpResponse";
|
||||
|
||||
export interface BulkInsertVideoBodyRequest {
|
||||
media_id: string;
|
||||
data: Array<{
|
||||
episode: number;
|
||||
videos: Array<{
|
||||
service_id: string;
|
||||
code: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const bulkInsertVideoController = async (
|
||||
ctx: Context & { body: BulkInsertVideoBodyRequest },
|
||||
) => {
|
||||
try {
|
||||
const insertedVideos = await bulkInsertVideoService(ctx.body);
|
||||
return returnWriteResponse(ctx.set, 201, "Videos inserted", insertedVideos);
|
||||
} catch (error) {
|
||||
throw mainErrorHandler(ctx.set, error);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { Context } from "elysia";
|
||||
import { mainErrorHandler } from "../../../helpers/error/handler";
|
||||
import { returnWriteResponse } from "../../../helpers/callback/httpResponse";
|
||||
import { createVideoServiceInternalService } from "../services/http/createVideoService.service";
|
||||
|
||||
export interface CreateVideoServiceBodyRequest {
|
||||
name: string;
|
||||
domain: string;
|
||||
logo: string;
|
||||
hexColor: string;
|
||||
endpointVideo: string;
|
||||
endpointThumbnail: string;
|
||||
endpointDownload?: string;
|
||||
}
|
||||
|
||||
export const createVideoServiceInternalController = async (
|
||||
ctx: Context & { body: CreateVideoServiceBodyRequest },
|
||||
) => {
|
||||
try {
|
||||
const createdVideoService = await createVideoServiceInternalService(
|
||||
ctx.body,
|
||||
);
|
||||
return returnWriteResponse(
|
||||
ctx.set,
|
||||
201,
|
||||
"Video service created",
|
||||
createdVideoService,
|
||||
);
|
||||
} catch (error) {
|
||||
throw mainErrorHandler(ctx.set, error);
|
||||
}
|
||||
};
|
||||
@ -1,7 +1,11 @@
|
||||
import Elysia from "elysia";
|
||||
import { bulkInsertEpisodeController } from "./controllers/bulkInsertEpisode.controller";
|
||||
import { bulkInsertMediaController } from "./controllers/bulkInsertMedia.controller";
|
||||
import { createVideoServiceInternalController } from "./controllers/createVideoService.controller";
|
||||
import { bulkInsertVideoController } from "./controllers/bulkInsertVideo.controller";
|
||||
|
||||
export const internalModule = new Elysia({ prefix: "/internal" })
|
||||
.post("/media/bulk-insert", bulkInsertMediaController)
|
||||
.post("/episode/bulk-insert", bulkInsertEpisodeController);
|
||||
.post("/episode/bulk-insert", bulkInsertEpisodeController)
|
||||
.post("/video/bulk-insert", bulkInsertVideoController)
|
||||
.post("/video-service", createVideoServiceInternalController);
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { AppError } from "../../../helpers/error/instances/app";
|
||||
import { prisma } from "../../../utils/databases/prisma/connection";
|
||||
import { generateUUIDv7 } from "../../../helpers/databases/uuidv7";
|
||||
|
||||
export const bulkInsertVideoRepository = async (
|
||||
payload: Omit<Prisma.VideoUncheckedCreateInput, "id">,
|
||||
) => {
|
||||
try {
|
||||
return await prisma.video.upsert({
|
||||
where: {
|
||||
serviceId_code: {
|
||||
serviceId: payload.serviceId,
|
||||
code: payload.code,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: generateUUIDv7(),
|
||||
...payload,
|
||||
},
|
||||
update: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError(500, "Error inserting video", error);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { AppError } from "../../../helpers/error/instances/app";
|
||||
import { prisma } from "../../../utils/databases/prisma/connection";
|
||||
import { generateUUIDv7 } from "../../../helpers/databases/uuidv7";
|
||||
|
||||
export const createVideoServiceInternalRepository = async (
|
||||
payload: Omit<Prisma.VideoServiceUncheckedCreateInput, "id">,
|
||||
) => {
|
||||
try {
|
||||
return await prisma.videoService.upsert({
|
||||
where: {
|
||||
name: payload.name,
|
||||
},
|
||||
create: {
|
||||
id: generateUUIDv7(),
|
||||
...payload,
|
||||
},
|
||||
update: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError(500, "Failed to create video service", error);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { AppError } from "../../../helpers/error/instances/app";
|
||||
import { prisma } from "../../../utils/databases/prisma/connection";
|
||||
|
||||
export const findEpisodeWithMediaIdRepository = async ({
|
||||
media,
|
||||
episode,
|
||||
}: {
|
||||
media: string;
|
||||
episode: number;
|
||||
}) => {
|
||||
try {
|
||||
const foundEpisode = await prisma.episode.findUnique({
|
||||
where: {
|
||||
mediaId_episode: {
|
||||
mediaId: media,
|
||||
episode: episode,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!foundEpisode) throw new AppError(404, "Episode not found");
|
||||
return foundEpisode;
|
||||
} catch (error) {
|
||||
throw new AppError(500, "Error finding episode with media id", error);
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getEpisodeReferenceAPI } from "../../../../config/apis/episode.reference";
|
||||
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
|
||||
import { MediaEpisodeInfoResponse } from "../../types/mediaEpisodeInfo.type";
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { SystemAccountId } from "../../../../config/account/system";
|
||||
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
|
||||
import { BulkInsertVideoBodyRequest } from "../../controllers/bulkInsertVideo.controller";
|
||||
import { findEpisodeWithMediaIdRepository } from "../../repositories/findEpisodeWithMediaId.repository";
|
||||
import { bulkInsertVideoRepository } from "../../repositories/bulkInsertVideo.repository";
|
||||
|
||||
export const bulkInsertVideoService = async (
|
||||
body: BulkInsertVideoBodyRequest,
|
||||
) => {
|
||||
try {
|
||||
const insertedVideos: string[] = [];
|
||||
for (const episodeData of body.data) {
|
||||
const episodeId = await findEpisodeWithMediaIdRepository({
|
||||
media: body.media_id,
|
||||
episode: episodeData.episode,
|
||||
});
|
||||
|
||||
for (const videoData of episodeData.videos) {
|
||||
const insertedVideo = await bulkInsertVideoRepository({
|
||||
episodeId: episodeId.id,
|
||||
serviceId: videoData.service_id,
|
||||
code: videoData.code,
|
||||
uploadedBy: SystemAccountId,
|
||||
});
|
||||
|
||||
insertedVideos.push(insertedVideo.id);
|
||||
}
|
||||
}
|
||||
|
||||
return insertedVideos;
|
||||
} catch (error) {
|
||||
ErrorForwarder(error);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { SystemAccountId } from "../../../../config/account/system";
|
||||
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
|
||||
import { CreateVideoServiceBodyRequest } from "../../controllers/createVideoService.controller";
|
||||
import { createVideoServiceInternalRepository } from "../../repositories/createVideoService.repository";
|
||||
|
||||
export const createVideoServiceInternalService = async (
|
||||
body: CreateVideoServiceBodyRequest,
|
||||
) => {
|
||||
try {
|
||||
return await createVideoServiceInternalRepository({
|
||||
name: body.name,
|
||||
domain: body.domain,
|
||||
logo: body.logo,
|
||||
hexColor: body.hexColor,
|
||||
endpointVideo: body.endpointVideo,
|
||||
endpointThumbnail: body.endpointThumbnail,
|
||||
endpointDownload: body.endpointDownload,
|
||||
createdBy: SystemAccountId,
|
||||
});
|
||||
} catch (error) {
|
||||
ErrorForwarder(error);
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,5 @@
|
||||
import { SystemAccountId } from "../../../../config/account/system";
|
||||
import { getContentReferenceAPI } from "../../../../config/apis/media.reference";
|
||||
import { generateUUIDv7 } from "../../../../helpers/databases/uuidv7";
|
||||
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
|
||||
import { bulkInsertCharactersRepository } from "../../repositories/bulkInsertCharacters.repository";
|
||||
import { bulkInsertLangVARepository } from "../../repositories/bulkInsertLangVA.repository";
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { SystemAccountId } from "../../../../config/account/system";
|
||||
import { generateUUIDv7 } from "../../../../helpers/databases/uuidv7";
|
||||
import { ErrorForwarder } from "../../../../helpers/error/instances/forwarder";
|
||||
import { bulkInsertVoiceActorRepository } from "../../repositories/bulkInsertVoiceActor.repository";
|
||||
import { Person } from "../../types/mediaCharWithVAInfo";
|
||||
|
||||
@ -34,12 +34,12 @@ interface Data {
|
||||
year: number;
|
||||
broadcast: Broadcast;
|
||||
producers: Genre[];
|
||||
licensors: any[];
|
||||
licensors: unknown[];
|
||||
studios: Genre[];
|
||||
genres: Genre[];
|
||||
explicit_genres: any[];
|
||||
explicit_genres: unknown[];
|
||||
themes: Genre[];
|
||||
demographics: any[];
|
||||
demographics: unknown[];
|
||||
relations: Relation[];
|
||||
theme: Theme;
|
||||
external: External[];
|
||||
|
||||
@ -10,7 +10,7 @@ interface Data {
|
||||
name: string;
|
||||
given_name: null;
|
||||
family_name: null;
|
||||
alternate_names: any[];
|
||||
alternate_names: string[];
|
||||
birthday: Date;
|
||||
favorites: number;
|
||||
about: string;
|
||||
|
||||
@ -6,14 +6,17 @@ const includeOptions = ["preference", "assignedRoles"] as const;
|
||||
export const getUserOptionsSchema = z.object({
|
||||
verbosity: z.enum(
|
||||
["exists", "basic", "full"],
|
||||
"option: verbosity value must match with enum types"
|
||||
"option: verbosity value must match with enum types",
|
||||
),
|
||||
include: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val?.split(",") ?? [])
|
||||
.refine(
|
||||
(arr) => arr.every((val) => includeOptions.includes(val.trim() as any)),
|
||||
"option: include value didn't match with enum types"
|
||||
(arr) =>
|
||||
arr.every((val) =>
|
||||
includeOptions.includes(val.trim() as typeof includeOptions[number]),
|
||||
),
|
||||
"option: include value didn't match with enum types",
|
||||
),
|
||||
});
|
||||
|
||||
@ -2,26 +2,6 @@ import { Prisma } from "@prisma/client";
|
||||
import { ErrorForwarder } from "../../../helpers/error/instances/forwarder";
|
||||
import { userSessionModel } from "../userSession.model";
|
||||
|
||||
type CreateUserSessionResponse = Prisma.UserSessionGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
deviceType: true;
|
||||
isAuthenticated: true;
|
||||
validUntil: true;
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
email: true;
|
||||
username: true;
|
||||
avatar: true;
|
||||
birthDate: true;
|
||||
bioProfile: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export const createUserSessionRepository = async (
|
||||
data: Prisma.UserSessionUncheckedCreateInput,
|
||||
) => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { minioBucketName, minioClient } from "../client";
|
||||
import { minioClient } from "../client";
|
||||
import { ensureBucketExists } from "../validations/ensureBucketExists";
|
||||
|
||||
export const getStreamFile = async (filename: string) => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { minioBucketName, minioClient, minioProtocol } from "../client";
|
||||
import { minioBucketName, minioClient } from "../client";
|
||||
import { ensureBucketExists } from "../validations/ensureBucketExists";
|
||||
import { Readable } from "stream";
|
||||
|
||||
@ -7,7 +7,7 @@ export const uploadFile = async (
|
||||
options?: {
|
||||
fileDir?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
},
|
||||
): Promise<string> => {
|
||||
// Ensure the target MinIO bucket exists before performing any upload
|
||||
await ensureBucketExists();
|
||||
|
||||
Reference in New Issue
Block a user