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 const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false, }, 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 { id: ID! name: String! description: String products: [Product!]! } type Product { id: ID! title: String! price: Float! category_id: Int created_by: ID 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(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 const metricsPlugin = { async requestDidStart() { return { async willSendResponse({ response, contextValue }) { if (response.body.kind === 'single') { response.body.singleResult.extensions = { ...response.body.singleResult.extensions, nPlusOneMetrics: { totalCalls: contextValue.metrics.categoryProductsCalls, resolverLogs: contextValue.metrics.callLogs, }, }; } }, }; }, }; // --- 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 () => { 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 }) => { 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 (_, { 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 }) => { 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) => { contextValue.metrics.categoryProductsCalls += 1; const currentCall = contextValue.metrics.categoryProductsCalls; contextValue.metrics.callLogs.push( `[resolver counter] Category.products dipanggil ${currentCall} kali (category_id=${parent.id})` ); 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'); } }, }, // Resolver Relasi Product -> Category (Many-to-One) Product: { category: async (parent) => { if (!parent.category_id) return 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'); } }, }, }; // 5. Inisialisasi Apollo Server dengan Plugin const server = new ApolloServer({ typeDefs, resolvers, introspection: true, csrfPrevention: false, plugins: [metricsPlugin], }); // 6. Inisialisasi Handler dengan Context Per-Request const apolloHandler = startServerAndCreateNextHandler(server, { context: async () => ({ metrics: { categoryProductsCalls: 0, callLogs: [], }, }), }); // 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 origin = req.headers.origin; if (allowedOrigins.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.setHeader( 'Access-Control-Allow-Headers', 'Content-Type, Authorization, apollo-require-preflight, x-apollo-operation-name' ); if (req.method === 'OPTIONS') { res.status(200).end(); return; } return apolloHandler(req, res); };