Files
TugasPaaS/server/server.js
T

432 lines
12 KiB
JavaScript

import bcrypt from "bcryptjs"
import cors from "cors"
import dotenv from "dotenv"
import express from "express"
import fs from "fs"
import jwt from "jsonwebtoken"
import multer from "multer"
import path from "path"
import { fileURLToPath } from "url"
import { pool } from "./db.js"
dotenv.config()
const app = express()
const port = Number(process.env.PORT || 5000)
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
const maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10)
const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6)
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const uploadsDir = path.join(__dirname, "uploads")
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
app.use(cors({
origin: process.env.CLIENT_URL || "http://localhost:5173",
credentials: true
}))
app.use(express.json())
app.use("/uploads", express.static(uploadsDir))
const storage = multer.diskStorage({
destination: uploadsDir,
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) {
cb(new Error("File harus berupa gambar."))
return
}
cb(null, true)
}
})
async function ensureSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
)
`)
}
function signUser(user) {
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
}
function publicUser(user) {
return {
id: user.id,
name: user.name,
email: user.email,
whatsapp: user.whatsapp,
role: user.role || "user"
}
}
function auth(req, res, next) {
const header = req.headers.authorization || ""
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
if (!token) {
res.status(401).json({ message: "Silakan login terlebih dahulu." })
return
}
try {
req.user = jwt.verify(token, jwtSecret)
next()
} catch {
res.status(401).json({ message: "Sesi login tidak valid." })
}
}
async function superadminOnly(req, res, next) {
try {
const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id])
if (!rows[0] || rows[0].role !== "superadmin") {
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
return
}
next()
} catch (error) {
next(error)
}
}
function uniqueImages(files) {
const seen = new Set()
return files.filter((file) => {
const key = `${file.originalname}-${file.size}-${file.mimetype}`
if (seen.has(key)) return false
seen.add(key)
return true
}).slice(0, maxImageCount)
}
function getUploadedImages(req) {
const files = [
...(req.files?.images || []),
...(req.files?.image || []),
...(req.files?.photos || [])
]
return uniqueImages(files)
}
async function getProductImages(productId, fallbackImage) {
const [images] = await pool.query(
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
[productId]
)
const imagePaths = images.map((item) => item.image).filter(Boolean)
if (imagePaths.length) return imagePaths
return fallbackImage ? [fallbackImage] : []
}
async function attachProductImages(products) {
return Promise.all(
products.map(async (product) => ({
...product,
images: await getProductImages(product.id, product.image)
}))
)
}
async function findProductById(id) {
const [rows] = await pool.query(
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
[id]
)
const product = rows[0]
if (!product) return null
product.images = await getProductImages(product.id, product.image)
return product
}
async function deleteUploadedImages(product) {
const images = product.images?.length ? product.images : [product.image]
images.forEach((image) => {
if (image?.startsWith("/uploads/")) {
fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {})
}
})
}
app.get("/api/health", async (req, res, next) => {
try {
await pool.query("SELECT 1")
res.json({ status: "ok" })
} catch (error) {
next(error)
}
})
app.get("/api/debug/products/:id/images", async (req, res, next) => {
try {
const product = await findProductById(req.params.id)
if (!product) {
res.status(404).json({ message: "Produk tidak ditemukan." })
return
}
res.json({
product_id: Number(req.params.id),
main_image: product.image,
image_count: product.images.length,
images: product.images
})
} catch (error) {
next(error)
}
})
app.post("/api/auth/register", async (req, res, next) => {
try {
const { name, email, whatsapp, password } = req.body
if (!name || !email || !whatsapp || !password) {
res.status(400).json({ message: "Semua field wajib diisi." })
return
}
if (password.length < 6) {
res.status(400).json({ message: "Password minimal 6 karakter." })
return
}
const passwordHash = await bcrypt.hash(password, 10)
const [result] = await pool.query(
"INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)",
[name, email.toLowerCase(), whatsapp, passwordHash]
)
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
res.status(201).json({ token: signUser(user), user: publicUser(user) })
} catch (error) {
if (error.code === "ER_DUP_ENTRY") {
res.status(409).json({ message: "Email sudah terdaftar." })
return
}
next(error)
}
})
app.post("/api/auth/login", async (req, res, next) => {
try {
const { email, password } = req.body
if (!email || !password) {
res.status(400).json({ message: "Email dan password wajib diisi." })
return
}
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()])
const user = rows[0]
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
res.status(401).json({ message: "Email atau password salah." })
return
}
res.json({ token: signUser(user), user: publicUser(user) })
} catch (error) {
next(error)
}
})
app.get("/api/auth/me", auth, async (req, res, next) => {
try {
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
if (!rows[0]) {
res.status(404).json({ message: "User tidak ditemukan." })
return
}
res.json({ user: rows[0] })
} catch (error) {
next(error)
}
})
app.get("/api/products", async (req, res, next) => {
try {
const [rows] = await pool.query(
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
)
res.json({ products: await attachProductImages(rows) })
} catch (error) {
next(error)
}
})
app.get("/api/products/mine", auth, async (req, res, next) => {
try {
const [rows] = await pool.query(
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
[req.user.id]
)
res.json({ products: await attachProductImages(rows) })
} catch (error) {
next(error)
}
})
app.get("/api/products/:id", async (req, res, next) => {
try {
const product = await findProductById(req.params.id)
if (!product) {
res.status(404).json({ message: "Produk tidak ditemukan." })
return
}
res.json({ product })
} catch (error) {
next(error)
}
})
app.post(
"/api/products",
auth,
upload.fields([
{ name: "images", maxCount: maxImageCount },
{ name: "image", maxCount: maxImageCount },
{ name: "photos", maxCount: maxImageCount }
]),
async (req, res, next) => {
try {
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
const uploadedImages = getUploadedImages(req)
console.log("Upload produk:", {
title,
imageCount: uploadedImages.length,
files: uploadedImages.map((file) => file.originalname)
})
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) {
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
return
}
const mainImagePath = `/uploads/${uploadedImages[0].filename}`
const [result] = await pool.query(
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
)
const imageRows = uploadedImages.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index])
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
const product = await findProductById(result.insertId)
res.status(201).json({ product })
} catch (error) {
next(error)
}
}
)
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
try {
const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia"
const [result] = await pool.query(
"UPDATE products SET status = ? WHERE id = ? AND user_id = ?",
[status, req.params.id, req.user.id]
)
if (!result.affectedRows) {
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
return
}
const product = await findProductById(req.params.id)
res.json({ product })
} catch (error) {
next(error)
}
})
app.delete("/api/products/:id", auth, async (req, res, next) => {
try {
const product = await findProductById(req.params.id)
if (!product || product.user_id !== req.user.id) {
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
return
}
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
await deleteUploadedImages(product)
res.json({ message: "Produk berhasil dihapus." })
} catch (error) {
next(error)
}
})
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
try {
const product = await findProductById(req.params.id)
if (!product) {
res.status(404).json({ message: "Produk tidak ditemukan." })
return
}
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
await deleteUploadedImages(product)
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
} catch (error) {
next(error)
}
})
app.use((error, req, res, next) => {
if (error instanceof multer.MulterError) {
res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
return
}
if (error.message === "File harus berupa gambar.") {
res.status(400).json({ message: error.message })
return
}
console.error(error)
res.status(500).json({
message: "Terjadi kesalahan pada server.",
detail: process.env.NODE_ENV === "production" ? undefined : error.message
})
})
ensureSchema()
.then(() => {
app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${port}`)
console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`)
})
})
.catch((error) => {
console.error("Gagal menyiapkan database:", error)
process.exit(1)
})