From d9456638d7620fab649fddae249343cf5f1d5271 Mon Sep 17 00:00:00 2001 From: MelvynFaith Date: Wed, 16 Sep 2026 17:24:54 +0700 Subject: [PATCH] feat: add product mutations, category filter, input validation, and DB constraints --- pages/api/graphql.js | 245 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 217 insertions(+), 28 deletions(-) diff --git a/pages/api/graphql.js b/pages/api/graphql.js index 6d6d071..ba410b9 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -1,5 +1,6 @@ const { ApolloServer } = require('@apollo/server'); const { startServerAndCreateNextHandler } = require('@as-integrations/next'); +const { GraphQLError } = require('graphql'); const { Pool } = require('pg'); // 1. Inisialisasi Pool PostgreSQL untuk Serverless @@ -11,6 +12,12 @@ const pool = new Pool({ max: 1, }); +// Cegah crash proses kalau koneksi idle di pool tiba-tiba error +// (umum terjadi di kombinasi Vercel serverless + Neon auto-suspend). +pool.on('error', (err) => { + console.error('Unexpected error on idle pg client:', err); +}); + // 2. Definisi Schema GraphQL const typeDefs = `#graphql type Category { @@ -29,12 +36,29 @@ const typeDefs = `#graphql category: Category } + input CreateProductInput { + title: String! + price: Float! + categoryId: ID! + } + + input UpdateProductInput { + title: String + price: Float + } + type Query { categories: [Category!]! category(id: ID!): Category - products: [Product!]! + products(categoryId: ID): [Product!]! product(id: ID!): Product } + + type Mutation { + createProduct(input: CreateProductInput!): Product! + updateProduct(id: ID!, input: UpdateProductInput!): Product! + deleteProduct(id: ID!): Boolean! + } `; // 3. Plugin Apollo untuk menyuntikkan log counter ke response extensions @@ -56,49 +80,202 @@ const metricsPlugin = { }, }; +// --- Helper validasi (dipakai berulang, biar konsisten & tidak duplikasi) --- + +// Pastikan value bisa jadi integer positif (untuk semua ID: category/product). +function parsePositiveIntId(value, fieldName) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new GraphQLError(`${fieldName} harus berupa angka bulat positif, diterima: "${value}"`, { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return parsed; +} + +// Pastikan title tidak kosong setelah di-trim. +function validateTitle(title) { + const trimmed = String(title).trim(); + if (!trimmed) { + throw new GraphQLError('Title tidak boleh kosong', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return trimmed; +} + +// Pastikan price adalah angka lebih besar dari 0. +function validatePrice(price) { + if (typeof price !== 'number' || Number.isNaN(price) || price <= 0) { + throw new GraphQLError('Price harus berupa angka lebih besar dari 0', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return price; +} + +// Bungkus error tak terduga dari DB supaya tidak bocor ke client, +// tapi biarkan GraphQLError yang sudah kita buat sendiri lewat apa adanya. +function handleUnexpectedError(err, context) { + if (err instanceof GraphQLError) throw err; + console.error(`[DB error] ${context}:`, err); + throw new GraphQLError('Terjadi kesalahan pada server, silakan coba lagi', { + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }); +} + // 4. Definisi Resolver const resolvers = { Query: { categories: async () => { - const result = await pool.query('SELECT * FROM categories ORDER BY id ASC'); - return result.rows; + try { + const result = await pool.query('SELECT * FROM categories ORDER BY id ASC'); + return result.rows; + } catch (err) { + handleUnexpectedError(err, 'Query.categories'); + } }, + category: async (_, { id }) => { - const result = await pool.query('SELECT * FROM categories WHERE id = $1', [id]); - return result.rows[0] || null; + try { + const categoryId = parsePositiveIntId(id, 'id'); + const result = await pool.query('SELECT * FROM categories WHERE id = $1', [categoryId]); + return result.rows[0] || null; + } catch (err) { + handleUnexpectedError(err, 'Query.category'); + } }, - products: async () => { - const result = await pool.query( - 'SELECT id, title, price::float, category_id, created_by FROM products ORDER BY id ASC' - ); - return result.rows; + + products: async (_, { categoryId }) => { + try { + if (categoryId !== undefined && categoryId !== null) { + const parsedCategoryId = parsePositiveIntId(categoryId, 'categoryId'); + const result = await pool.query( + 'SELECT id, title, price::float, category_id, created_by FROM products WHERE category_id = $1 ORDER BY id ASC', + [parsedCategoryId] + ); + return result.rows; + } + const result = await pool.query( + 'SELECT id, title, price::float, category_id, created_by FROM products ORDER BY id ASC' + ); + return result.rows; + } catch (err) { + handleUnexpectedError(err, 'Query.products'); + } }, + product: async (_, { id }) => { - const result = await pool.query( - 'SELECT id, title, price::float, category_id, created_by FROM products WHERE id = $1', - [id] - ); - return result.rows[0] || null; + try { + const productId = parsePositiveIntId(id, 'id'); + const result = await pool.query( + 'SELECT id, title, price::float, category_id, created_by FROM products WHERE id = $1', + [productId] + ); + return result.rows[0] || null; + } catch (err) { + handleUnexpectedError(err, 'Query.product'); + } + }, + }, + + Mutation: { + createProduct: async (_, { input }) => { + try { + const title = validateTitle(input.title); + const price = validatePrice(input.price); + const categoryId = parsePositiveIntId(input.categoryId, 'categoryId'); + + // Cek dulu kategorinya ada, supaya error-nya jelas (bukan raw FK violation). + const categoryCheck = await pool.query('SELECT id FROM categories WHERE id = $1', [categoryId]); + if (categoryCheck.rows.length === 0) { + throw new GraphQLError(`Kategori dengan id ${categoryId} tidak ditemukan`, { + extensions: { code: 'NOT_FOUND' }, + }); + } + + const result = await pool.query( + 'INSERT INTO products (title, price, category_id) VALUES ($1, $2, $3) RETURNING id, title, price::float, category_id, created_by', + [title, price, categoryId] + ); + return result.rows[0]; + } catch (err) { + // Jaga-jaga kalau ada race condition kategori terhapus di antara cek & insert. + if (err && err.code === '23503') { + throw new GraphQLError('Kategori yang dirujuk tidak valid', { + extensions: { code: 'NOT_FOUND' }, + }); + } + handleUnexpectedError(err, 'Mutation.createProduct'); + } + }, + + updateProduct: async (_, { id, input }) => { + try { + const productId = parsePositiveIntId(id, 'id'); + + const hasTitle = input.title !== undefined && input.title !== null; + const hasPrice = input.price !== undefined && input.price !== null; + if (!hasTitle && !hasPrice) { + throw new GraphQLError('Minimal satu field (title atau price) harus diisi untuk update', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const title = hasTitle ? validateTitle(input.title) : null; + const price = hasPrice ? validatePrice(input.price) : null; + + const result = await pool.query( + `UPDATE products + SET title = COALESCE($1, title), + price = COALESCE($2, price), + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + RETURNING id, title, price::float, category_id, created_by`, + [title, price, productId] + ); + + if (!result.rows[0]) { + throw new GraphQLError(`Product dengan id ${productId} tidak ditemukan`, { + extensions: { code: 'NOT_FOUND' }, + }); + } + return result.rows[0]; + } catch (err) { + handleUnexpectedError(err, 'Mutation.updateProduct'); + } + }, + + deleteProduct: async (_, { id }) => { + try { + const productId = parsePositiveIntId(id, 'id'); + const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING id', [productId]); + return result.rowCount > 0; + } catch (err) { + handleUnexpectedError(err, 'Mutation.deleteProduct'); + } }, }, // Resolver Relasi Category -> Products (One-to-Many) Category: { products: async (parent, _, contextValue) => { - // Increment counter di context request saat ini contextValue.metrics.categoryProductsCalls += 1; const currentCall = contextValue.metrics.categoryProductsCalls; - // Simpan log urutan pemanggilan ke array context contextValue.metrics.callLogs.push( `[resolver counter] Category.products dipanggil ${currentCall} kali (category_id=${parent.id})` ); - const result = await pool.query( - 'SELECT id, title, price::float, category_id, created_by FROM products WHERE category_id = $1 ORDER BY id ASC', - [parent.id] - ); - return result.rows; + try { + const result = await pool.query( + 'SELECT id, title, price::float, category_id, created_by FROM products WHERE category_id = $1 ORDER BY id ASC', + [parent.id] + ); + return result.rows; + } catch (err) { + handleUnexpectedError(err, 'Category.products'); + } }, }, @@ -106,8 +283,12 @@ const resolvers = { Product: { category: async (parent) => { if (!parent.category_id) return null; - const result = await pool.query('SELECT * FROM categories WHERE id = $1', [parent.category_id]); - return result.rows[0] || null; + try { + const result = await pool.query('SELECT * FROM categories WHERE id = $1', [parent.category_id]); + return result.rows[0] || null; + } catch (err) { + handleUnexpectedError(err, 'Product.category'); + } }, }, }; @@ -132,11 +313,19 @@ const apolloHandler = startServerAndCreateNextHandler(server, { }); // 7. Handler CORS dan Serverless Entrypoint +// ALLOWED_ORIGINS bisa diisi di env Vercel, dipisah koma, misal: +// "https://frontend-anda.vercel.app,https://domain-lain.com" +const defaultAllowedOrigins = [ + 'https://studio.apollographql.com', + 'https://embeddable-sandbox.cdn.apollographql.com', +]; +const extraAllowedOrigins = (process.env.ALLOWED_ORIGINS || '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); +const allowedOrigins = new Set([...defaultAllowedOrigins, ...extraAllowedOrigins]); + module.exports = async function handler(req, res) { - const allowedOrigins = new Set([ - 'https://studio.apollographql.com', - 'https://embeddable-sandbox.cdn.apollographql.com', - ]); const origin = req.headers.origin; if (allowedOrigins.has(origin)) {