🚧 wip: create bulk insert endpoint

This commit is contained in:
Rafi Arrafif
2026-01-23 21:08:10 +07:00
parent 87ec339dba
commit 4c1f891f12
10 changed files with 440 additions and 83 deletions

View File

@ -24,6 +24,7 @@
"mock-aws-s3": "^4.0.2", "mock-aws-s3": "^4.0.2",
"nock": "^14.0.4", "nock": "^14.0.4",
"pg": "^8.17.1", "pg": "^8.17.1",
"slugify": "^1.6.6",
"zod": "^4.0.5", "zod": "^4.0.5",
}, },
"devDependencies": { "devDependencies": {
@ -1150,6 +1151,8 @@
"slice-ansi": ["slice-ansi@1.0.0", "", { "dependencies": { "is-fullwidth-code-point": "^2.0.0" } }, "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg=="], "slice-ansi": ["slice-ansi@1.0.0", "", { "dependencies": { "is-fullwidth-code-point": "^2.0.0" } }, "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg=="],
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
"spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="],
"spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],

View File

@ -9,6 +9,7 @@
"lint": "bunx eslint", "lint": "bunx eslint",
"commit": "bun x git-cz", "commit": "bun x git-cz",
"push": "bun scripts/git-multipush.ts", "push": "bun scripts/git-multipush.ts",
"prisma:push": "bunx prisma db push",
"prisma:generate": "bunx prisma generate", "prisma:generate": "bunx prisma generate",
"prisma:dbml": "bunx prisma db pull && bunx prisma dbml --output ./prisma/dbml/schema.dbml", "prisma:dbml": "bunx prisma db pull && bunx prisma dbml --output ./prisma/dbml/schema.dbml",
"prisma:seed": "bun run ./prisma/seed/index.ts", "prisma:seed": "bun run ./prisma/seed/index.ts",
@ -35,6 +36,7 @@
"mock-aws-s3": "^4.0.2", "mock-aws-s3": "^4.0.2",
"nock": "^14.0.4", "nock": "^14.0.4",
"pg": "^8.17.1", "pg": "^8.17.1",
"slugify": "^1.6.6",
"zod": "^4.0.5" "zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -6,23 +6,22 @@ Table medias {
id String [pk] id String [pk]
title String [not null] title String [not null]
titleAlternative Json [not null] titleAlternative Json [not null]
slug String [not null] slug String [unique, not null]
malId Int [unique]
pictureMedium String [not null] pictureMedium String [not null]
pictureLarge String [not null] pictureLarge String [not null]
genres genres [not null] genres genres [not null]
country countries [not null] country Country [not null, default: 'JP']
countryId String [not null] score Decimal [not null, default: 0]
isAiring Boolean [not null, default: false] status String [not null]
isTba Boolean [not null, default: false] startAiring DateTime
startAiring DateTime [not null] endAiring DateTime
endAiring DateTime [not null]
synopsis String [not null] synopsis String [not null]
nfsw Boolean [not null, default: false] ageRating String [not null]
ageRating AgeRating [not null]
mediaType MediaType [not null] mediaType MediaType [not null]
source Source [not null] source String
studios studios [not null] studios studios [not null]
pendingUpload Boolean [not null, default: true] onDraft Boolean [not null, default: true]
uploader users [not null] uploader users [not null]
uploadedBy String [not null] uploadedBy String [not null]
deletedAt DateTime deletedAt DateTime
@ -51,7 +50,7 @@ Table media_logs {
Table genres { Table genres {
id String [pk] id String [pk]
name String [not null] name String [not null]
slug String [not null] slug String [unique, not null]
malId Int [not null] malId Int [not null]
malUrl String [not null] malUrl String [not null]
creator users [not null] creator users [not null]
@ -66,9 +65,10 @@ Table genres {
Table studios { Table studios {
id String [pk] id String [pk]
name String [not null] name String [not null]
slug String [not null] slug String [unique, not null]
logoUrl String [not null] linkAbout String [not null]
colorHex String [not null] malId Int [not null]
logoUrl String
creator users [not null] creator users [not null]
createdBy String [not null] createdBy String [not null]
deletedAt DateTime deletedAt DateTime
@ -77,20 +77,6 @@ Table studios {
medias medias [not null] medias medias [not null]
} }
Table countries {
id String [pk]
name String [not null]
code String [not null]
flag String [not null]
creator users [not null]
createdBy String [not null]
deletedAt DateTime
createdAt DateTime [default: `now()`, not null]
updatedAt DateTime [default: `now()`, not null]
medias medias [not null]
user_show_countries user_preferences [not null]
}
Table episodes { Table episodes {
id String [pk] id String [pk]
media medias [not null] media medias [not null]
@ -186,7 +172,6 @@ Table users {
media_approveds media_logs [not null] media_approveds media_logs [not null]
genres genres [not null] genres genres [not null]
studios studios [not null] studios studios [not null]
countries countries [not null]
episodes episodes [not null] episodes episodes [not null]
episode_likes episode_likes [not null] episode_likes episode_likes [not null]
videos videos [not null] videos videos [not null]
@ -221,7 +206,7 @@ Table user_preferences {
videoQuality VideoQuality [not null, default: 'Q1080'] videoQuality VideoQuality [not null, default: 'Q1080']
serviceDefault video_services serviceDefault video_services
serviceDefaultId String serviceDefaultId String
showContries countries [not null] hideContries Country[] [not null]
favoriteGenres genres [not null] favoriteGenres genres [not null]
createdAt DateTime [default: `now()`, not null] createdAt DateTime [default: `now()`, not null]
updatedAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null]
@ -495,25 +480,11 @@ Table UserFavoriteGenres {
favoritegenresId String [ref: > genres.id] favoritegenresId String [ref: > genres.id]
} }
Table UserShowContries {
user_show_countriesId String [ref: > user_preferences.id]
showcontriesId String [ref: > countries.id]
}
Table UserSelectedSharingCollention { Table UserSelectedSharingCollention {
allowed_collectionsId String [ref: > collections.id] allowed_collectionsId String [ref: > collections.id]
usersallowedId String [ref: > users.id] usersallowedId String [ref: > users.id]
} }
Enum AgeRating {
G
PG
PG_13
R
R_plus
Rx
}
Enum MediaType { Enum MediaType {
TV TV
ONA ONA
@ -523,11 +494,11 @@ Enum MediaType {
Music Music
} }
Enum Source { Enum Country {
original JP
manga EN
light_novel ID
game KR
} }
Enum MediaOperation { Enum MediaOperation {
@ -622,8 +593,6 @@ Enum TypeSystemNotification {
toast toast
} }
Ref: medias.countryId > countries.id
Ref: medias.uploadedBy > users.id Ref: medias.uploadedBy > users.id
Ref: media_logs.proposedBy > users.id Ref: media_logs.proposedBy > users.id
@ -636,8 +605,6 @@ Ref: genres.createdBy > users.id
Ref: studios.createdBy > users.id Ref: studios.createdBy > users.id
Ref: countries.createdBy > users.id
Ref: episodes.mediaId > medias.id Ref: episodes.mediaId > medias.id
Ref: episodes.uploadedBy > users.id Ref: episodes.uploadedBy > users.id

View File

@ -32,16 +32,16 @@ model Media {
pictureLarge String @db.Text pictureLarge String @db.Text
genres Genre[] @relation("MediaGenres") genres Genre[] @relation("MediaGenres")
country Country @default(JP) country Country @default(JP)
status Status score Decimal @db.Decimal(4, 2) @default(0.00)
status String
startAiring DateTime? startAiring DateTime?
finishAiring DateTime? endAiring DateTime?
synopsis String @db.Text synopsis String @db.Text
nfsw Boolean @default(false) ageRating String
ageRating AgeRating
mediaType MediaType mediaType MediaType
source Source source String?
studios Studio[] @relation("MediaStudios") studios Studio[] @relation("MediaStudios")
onDraft Boolean @default(false) onDraft Boolean @default(true)
uploader User @relation("UserUploadedMedias", fields: [uploadedBy], references: [id]) uploader User @relation("UserUploadedMedias", fields: [uploadedBy], references: [id])
uploadedBy String uploadedBy String
deletedAt DateTime? deletedAt DateTime?
@ -52,6 +52,11 @@ model Media {
episodes Episode[] @relation("MediaEpisodes") episodes Episode[] @relation("MediaEpisodes")
collections Collection[] @relation("MediaCollections") collections Collection[] @relation("MediaCollections")
reviews MediaReview[] @relation("MediaReviews") reviews MediaReview[] @relation("MediaReviews")
@@index([status, onDraft, deletedAt])
@@index([mediaType])
@@index([uploadedBy])
@@index([createdAt])
@@map("medias") @@map("medias")
} }
@ -73,7 +78,7 @@ model MediaLog {
model Genre { model Genre {
id String @id @default(uuid()) id String @id @default(uuid())
name String @db.VarChar(255) name String @db.VarChar(255)
slug String @db.VarChar(255) slug String @db.VarChar(255) @unique
malId Int malId Int
malUrl String @db.VarChar(255) malUrl String @db.VarChar(255)
creator User @relation("UserCreatedGenres", fields: [createdBy], references: [id]) creator User @relation("UserCreatedGenres", fields: [createdBy], references: [id])
@ -84,15 +89,17 @@ model Genre {
medias Media[] @relation("MediaGenres") medias Media[] @relation("MediaGenres")
user_favourite_genres UserPreference[] @relation("UserFavoriteGenres") user_favourite_genres UserPreference[] @relation("UserFavoriteGenres")
@@map("genres") @@map("genres")
} }
model Studio { model Studio {
id String @id @default(uuid()) id String @id @default(uuid())
name String @db.VarChar(255) name String @db.VarChar(255)
slug String @db.VarChar(255) slug String @db.VarChar(255) @unique
logoUrl String @db.Text linkAbout String @db.Text
colorHex String @db.VarChar(10) malId Int
logoUrl String? @db.Text
creator User @relation("UserCreatedStudios", fields: [createdBy], references: [id]) creator User @relation("UserCreatedStudios", fields: [createdBy], references: [id])
createdBy String createdBy String
deletedAt DateTime? deletedAt DateTime?
@ -103,6 +110,40 @@ model Studio {
@@map("studios") @@map("studios")
} }
model Character {
id String @id @default(uuid()) @db.Uuid
name String
role CharacterRole
favorites Int @default(0)
imageUrl String?
smallImageUrl String?
createdBy User @relation("UserCreatedCharacters", fields: [creatorId], references: [id])
creatorId String
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("characters")
}
model VoiceActor {
id String @id @default(uuid()) @db.Uuid
malId Int
name String
birthday DateTime?
description String @db.Text
aboutUrl String
imageUrl String?
websiteUrl String?
createdBy User @relation("UserCreatedVoiceActors", fields: [creatorId], references: [id])
creatorId String
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("voice_actors")
}
model Episode { model Episode {
id String @id @default(uuid()) id String @id @default(uuid())
media Media @relation("MediaEpisodes", fields: [mediaId], references: [id]) media Media @relation("MediaEpisodes", fields: [mediaId], references: [id])
@ -205,6 +246,8 @@ model User {
media_approveds MediaLog[] @relation("UserApprovedMedias") media_approveds MediaLog[] @relation("UserApprovedMedias")
genres Genre[] @relation("UserCreatedGenres") genres Genre[] @relation("UserCreatedGenres")
studios Studio[] @relation("UserCreatedStudios") studios Studio[] @relation("UserCreatedStudios")
characters Character[] @relation("UserCreatedCharacters")
voice_actor VoiceActor[] @relation("UserCreatedVoiceActors")
episodes Episode[] @relation("UserEpisodes") episodes Episode[] @relation("UserEpisodes")
episode_likes EpisodeLike[] @relation("UserEpisodeLikes") episode_likes EpisodeLike[] @relation("UserEpisodeLikes")
videos Video[] @relation("UserVideos") videos Video[] @relation("UserVideos")
@ -523,14 +566,6 @@ model SystemLog {
//// Prisma Enum Values //// //// Prisma Enum Values ////
// Media Enum // Media Enum
enum AgeRating {
G // All Ages
PG // Children
PG_13 // Teens 13 or older
R // 17+ (violance & profanity)
R_plus // Mild Nudity
Rx // Hentai
}
enum MediaType { enum MediaType {
TV TV
ONA ONA
@ -539,17 +574,6 @@ enum MediaType {
Special Special
Music Music
} }
enum Source {
original
manga
light_novel
game
}
enum Status {
FINISHED_AIRING @map("Finished Airing")
CURRENTLY_AIRING @map("Currently Airing")
NOT_YET_AIRED @map("Not yet aired")
}
enum Country { enum Country {
JP @map("Japanese") JP @map("Japanese")
EN @map("English") EN @map("English")
@ -557,6 +581,12 @@ enum Country {
KR @map("Korea") KR @map("Korea")
} }
// Character Enum
enum CharacterRole {
Main
Supporting
}
// MediaLog Enum // MediaLog Enum
enum MediaOperation { enum MediaOperation {
create create

15
snowflake.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
declare module "node-snowflake" {
interface SnowflakeOptions {
workerId?: number;
datacenterId?: number;
sequence?: number;
epoch?: number | bigint | Date;
}
class Snowflake {
constructor(options?: SnowflakeOptions);
generate(): bigint;
}
export { Snowflake };
}

View File

@ -0,0 +1,6 @@
export const getMediaReferenceAPI = (malId: number) => {
return {
baseURL: "https://api.jikan.moe/v4",
getMediaFullInfo: `/anime/${malId}/full`,
};
};

View File

@ -0,0 +1,50 @@
import slugify from "slugify";
import { PrismaClient } from "@prisma/client";
import { prisma } from "../../utils/databases/prisma/connection";
interface UniqueConfig {
model?: keyof PrismaClient;
target?: string;
}
export async function generateSlug(
input: string,
config?: UniqueConfig,
): Promise<string> {
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.`);
}
let counter = 1;
while (true) {
const exists = await prismaModel.findFirst({
where: {
[target]: uniqueSlug,
},
select: { [target]: true },
});
if (!exists) break;
uniqueSlug = `${baseSlug}-${counter}`;
counter++;
}
return uniqueSlug;
}

View File

@ -0,0 +1,25 @@
import Elysia, { Context } from "elysia";
import { MediaFullInfoResponse } from "./types/mediaFullInfo.type";
import { InsertMediaRepository } from "./repositories/insertMedia.repository";
import { mainErrorHandler } from "../../helpers/error/handler";
const masterSourceAPI = "https://api.jikan.moe/v4";
export const internalModule = new Elysia({ prefix: "/internal" }).post(
"/medias",
async (ctx: Context & { body: { mal_id: number } }) => {
try {
const fullMediaData = await fetch(
`${masterSourceAPI}/anime/${ctx.body.mal_id}/full`,
)
.then((res) => res.json())
.then((data) => data as MediaFullInfoResponse);
// return fullMediaData;
const createMedia = await InsertMediaRepository(fullMediaData);
return createMedia;
} catch (error) {
return mainErrorHandler(ctx.set, error);
}
},
);

View File

@ -0,0 +1,134 @@
import { Prisma } from "@prisma/client";
import { generateSlug } from "../../../helpers/characters/generateSlug";
import { AppError } from "../../../helpers/error/instances/app";
import { prisma } from "../../../utils/databases/prisma/connection";
import { MediaFullInfoResponse } from "../types/mediaFullInfo.type";
export const InsertMediaRepository = async (data: MediaFullInfoResponse) => {
try {
/**
* Genres Insertion
*
* This section handles the insertion of genres associated with the media.
* It iterates over each genre in the media data, generates a slug for it,
* and performs an upsert operation to ensure that the genre is either created
* or updated in the database. The IDs of the inserted or updated genres are
* collected for later association with the media.
*
* @param data - The full media data containing genres information.
*/
const genreIds: string[] = [];
for (const genre of data.data.genres) {
const slug = (await generateSlug(genre.name)) as string;
const genrePayload = {
name: genre.name,
malId: genre.mal_id,
malUrl: genre.url,
createdBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da",
slug,
};
const insertedGenre = await prisma.genre.upsert({
where: { slug },
create: genrePayload,
update: genrePayload,
select: { id: true },
});
genreIds.push(insertedGenre.id);
}
/**
* Studios Insertion
*
* This section manages the insertion of studios associated with the media.
* It processes each studio listed in the media data, generating a slug for
* each and performing an upsert operation to either create or update the
* studio record in the database. The IDs of the inserted or updated studios
* are collected for later association with the media.
*
* @param data - The full media data containing studios information.
*/
const studioIds: string[] = [];
for (const studio of data.data.studios) {
const slug = (await generateSlug(studio.name)) as string;
const studioPayload = {
name: studio.name,
malId: studio.mal_id,
linkAbout: studio.url,
createdBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da",
slug,
};
const insertedStudio = await prisma.studio.upsert({
where: { slug },
create: studioPayload,
update: studioPayload,
select: { id: true },
});
studioIds.push(insertedStudio.id);
}
for (const studio of data.data.producers) {
const slug = (await generateSlug(studio.name)) as string;
const studioPayload = {
name: studio.name,
malId: studio.mal_id,
linkAbout: studio.url,
createdBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da",
slug,
};
const insertedStudio = await prisma.studio.upsert({
where: { slug },
create: studioPayload,
update: studioPayload,
select: { id: true },
});
studioIds.push(insertedStudio.id);
}
/**
* Media Payload Construction and Upsert
*
* This section constructs the payload for the media insertion or update.
* It gathers all necessary information from the media data, including
* title, alternative titles, slug, associated genres and studios, score,
* images, status, airing dates, synopsis, age rating, media type, source,
* and other relevant details. This payload is then used in an upsert
* operation to ensure that the media record is either created or updated
* in the database.
*
* @param data - The full media data for constructing the media payload.
*/
const construct = {
title: data.data.title,
titleAlternative: (data.data.titles as unknown) as Prisma.InputJsonValue,
slug: await generateSlug(data.data.title, {
model: "media",
target: "slug",
}),
malId: data.data.mal_id,
genres: {
connect: genreIds.map((id) => ({ id })),
},
studios: {
connect: studioIds.map((id) => ({ id })),
},
score: data.data.score,
pictureMedium: data.data.images.webp.image_url,
pictureLarge: data.data.images.webp.large_image_url,
status: data.data.status,
startAiring: data.data.aired.from,
endAiring: data.data.aired.to,
synopsis: data.data.synopsis,
ageRating: data.data.rating,
mediaType: data.data.type,
source: data.data.source,
onDraft: false,
uploadedBy: "b734b9bc-b4ea-408f-a80e-0a837ce884da",
};
return await prisma.media.upsert({
where: { malId: data.data.mal_id },
update: construct,
create: construct,
});
} catch (error) {
throw new AppError(500, "Failed to insert media", error);
}
};

View File

@ -0,0 +1,125 @@
import { MediaType } from "@prisma/client";
export interface MediaFullInfoResponse {
data: Data;
}
interface Data {
mal_id: number;
url: string;
images: { [key: string]: Image };
trailer: Trailer;
approved: boolean;
titles: Title[];
title: string;
title_english: string;
title_japanese: string;
title_synonyms: string[];
type: MediaType;
source: string;
episodes: number;
status: string;
airing: boolean;
aired: Aired;
duration: string;
rating: string;
score: number;
scored_by: number;
rank: number;
popularity: number;
members: number;
favorites: number;
synopsis: string;
background: string;
season: string;
year: number;
broadcast: Broadcast;
producers: Genre[];
licensors: any[];
studios: Genre[];
genres: Genre[];
explicit_genres: any[];
themes: Genre[];
demographics: any[];
relations: Relation[];
theme: Theme;
external: External[];
streaming: External[];
}
interface Aired {
from: Date;
to: Date;
prop: Prop;
string: string;
}
interface Prop {
from: From;
to: From;
}
interface From {
day: number;
month: number;
year: number;
}
interface Broadcast {
day: string;
time: string;
timezone: string;
string: string;
}
interface External {
name: string;
url: string;
}
interface Genre {
mal_id: number;
type: Type;
name: string;
url: string;
}
enum Type {
Anime = "anime",
Manga = "manga",
}
interface Image {
image_url: string;
small_image_url: string;
large_image_url: string;
}
interface Relation {
relation: string;
entry: Genre[];
}
interface Theme {
openings: string[];
endings: string[];
}
interface Title {
type: string;
title: string;
}
interface Trailer {
youtube_id: null;
url: null;
embed_url: string;
images: Images;
}
interface Images {
image_url: null;
small_image_url: null;
medium_image_url: null;
large_image_url: null;
maximum_image_url: null;
}