feat: add product mutations, category filter, input validation, and DB constraints
This commit is contained in:
parent
e86e5d3bbd
commit
d9456638d7
@ -1,5 +1,6 @@
|
|||||||
const { ApolloServer } = require('@apollo/server');
|
const { ApolloServer } = require('@apollo/server');
|
||||||
const { startServerAndCreateNextHandler } = require('@as-integrations/next');
|
const { startServerAndCreateNextHandler } = require('@as-integrations/next');
|
||||||
|
const { GraphQLError } = require('graphql');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
// 1. Inisialisasi Pool PostgreSQL untuk Serverless
|
// 1. Inisialisasi Pool PostgreSQL untuk Serverless
|
||||||
@ -11,6 +12,12 @@ const pool = new Pool({
|
|||||||
max: 1,
|
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
|
// 2. Definisi Schema GraphQL
|
||||||
const typeDefs = `#graphql
|
const typeDefs = `#graphql
|
||||||
type Category {
|
type Category {
|
||||||
@ -29,12 +36,29 @@ const typeDefs = `#graphql
|
|||||||
category: Category
|
category: Category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CreateProductInput {
|
||||||
|
title: String!
|
||||||
|
price: Float!
|
||||||
|
categoryId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateProductInput {
|
||||||
|
title: String
|
||||||
|
price: Float
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
categories: [Category!]!
|
categories: [Category!]!
|
||||||
category(id: ID!): Category
|
category(id: ID!): Category
|
||||||
products: [Product!]!
|
products(categoryId: ID): [Product!]!
|
||||||
product(id: 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
|
// 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
|
// 4. Definisi Resolver
|
||||||
const resolvers = {
|
const resolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
categories: async () => {
|
categories: async () => {
|
||||||
|
try {
|
||||||
const result = await pool.query('SELECT * FROM categories ORDER BY id ASC');
|
const result = await pool.query('SELECT * FROM categories ORDER BY id ASC');
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
} catch (err) {
|
||||||
|
handleUnexpectedError(err, 'Query.categories');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
category: async (_, { id }) => {
|
category: async (_, { id }) => {
|
||||||
const result = await pool.query('SELECT * FROM categories WHERE id = $1', [id]);
|
try {
|
||||||
|
const categoryId = parsePositiveIntId(id, 'id');
|
||||||
|
const result = await pool.query('SELECT * FROM categories WHERE id = $1', [categoryId]);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
|
} catch (err) {
|
||||||
|
handleUnexpectedError(err, 'Query.category');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
products: async () => {
|
|
||||||
|
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(
|
const result = await pool.query(
|
||||||
'SELECT id, title, price::float, category_id, created_by FROM products ORDER BY id ASC'
|
'SELECT id, title, price::float, category_id, created_by FROM products ORDER BY id ASC'
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
} catch (err) {
|
||||||
|
handleUnexpectedError(err, 'Query.products');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
product: async (_, { id }) => {
|
product: async (_, { id }) => {
|
||||||
|
try {
|
||||||
|
const productId = parsePositiveIntId(id, 'id');
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, title, price::float, category_id, created_by FROM products WHERE id = $1',
|
'SELECT id, title, price::float, category_id, created_by FROM products WHERE id = $1',
|
||||||
[id]
|
[productId]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
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)
|
// Resolver Relasi Category -> Products (One-to-Many)
|
||||||
Category: {
|
Category: {
|
||||||
products: async (parent, _, contextValue) => {
|
products: async (parent, _, contextValue) => {
|
||||||
// Increment counter di context request saat ini
|
|
||||||
contextValue.metrics.categoryProductsCalls += 1;
|
contextValue.metrics.categoryProductsCalls += 1;
|
||||||
const currentCall = contextValue.metrics.categoryProductsCalls;
|
const currentCall = contextValue.metrics.categoryProductsCalls;
|
||||||
|
|
||||||
// Simpan log urutan pemanggilan ke array context
|
|
||||||
contextValue.metrics.callLogs.push(
|
contextValue.metrics.callLogs.push(
|
||||||
`[resolver counter] Category.products dipanggil ${currentCall} kali (category_id=${parent.id})`
|
`[resolver counter] Category.products dipanggil ${currentCall} kali (category_id=${parent.id})`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, title, price::float, category_id, created_by FROM products WHERE category_id = $1 ORDER BY id ASC',
|
'SELECT id, title, price::float, category_id, created_by FROM products WHERE category_id = $1 ORDER BY id ASC',
|
||||||
[parent.id]
|
[parent.id]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
} catch (err) {
|
||||||
|
handleUnexpectedError(err, 'Category.products');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -106,8 +283,12 @@ const resolvers = {
|
|||||||
Product: {
|
Product: {
|
||||||
category: async (parent) => {
|
category: async (parent) => {
|
||||||
if (!parent.category_id) return null;
|
if (!parent.category_id) return null;
|
||||||
|
try {
|
||||||
const result = await pool.query('SELECT * FROM categories WHERE id = $1', [parent.category_id]);
|
const result = await pool.query('SELECT * FROM categories WHERE id = $1', [parent.category_id]);
|
||||||
return result.rows[0] || null;
|
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
|
// 7. Handler CORS dan Serverless Entrypoint
|
||||||
module.exports = async function handler(req, res) {
|
// ALLOWED_ORIGINS bisa diisi di env Vercel, dipisah koma, misal:
|
||||||
const allowedOrigins = new Set([
|
// "https://frontend-anda.vercel.app,https://domain-lain.com"
|
||||||
|
const defaultAllowedOrigins = [
|
||||||
'https://studio.apollographql.com',
|
'https://studio.apollographql.com',
|
||||||
'https://embeddable-sandbox.cdn.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 origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
|
|
||||||
if (allowedOrigins.has(origin)) {
|
if (allowedOrigins.has(origin)) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user