fitur fitur darkmode dan foto foto

This commit is contained in:
2026-05-19 10:47:55 +07:00
parent 7e1cb94689
commit 6c36f7565e
57 changed files with 7867 additions and 236 deletions
+163 -55
View File
@@ -14,6 +14,8 @@ 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")
@@ -39,7 +41,7 @@ const storage = multer.diskStorage({
const upload = multer({
storage,
limits: { fileSize: 3 * 1024 * 1024 },
limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) {
cb(new Error("File harus berupa gambar."))
@@ -49,6 +51,21 @@ const upload = multer({
}
})
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" })
}
@@ -80,12 +97,81 @@ function auth(req, res, next) {
}
}
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]
)
return rows[0]
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) => {
@@ -97,6 +183,24 @@ app.get("/api/health", async (req, res, next) => {
}
})
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
@@ -169,7 +273,7 @@ app.get("/api/products", async (req, res, next) => {
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: rows })
res.json({ products: await attachProductImages(rows) })
} catch (error) {
next(error)
}
@@ -181,7 +285,7 @@ app.get("/api/products/mine", auth, async (req, res, next) => {
"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: rows })
res.json({ products: await attachProductImages(rows) })
} catch (error) {
next(error)
}
@@ -200,27 +304,46 @@ app.get("/api/products/:id", async (req, res, next) => {
}
})
app.post("/api/products", auth, upload.single("image"), async (req, res, next) => {
try {
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
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)
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.file) {
res.status(400).json({ message: "Semua field produk dan foto wajib diisi." })
return
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)
}
const imagePath = `/uploads/${req.file.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, imagePath, description]
)
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 {
@@ -251,10 +374,7 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
}
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
if (product.image.startsWith("/uploads/")) {
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
}
await deleteUploadedImages(product)
res.json({ message: "Produk berhasil dihapus." })
} catch (error) {
@@ -272,10 +392,7 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex
}
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
if (product.image.startsWith("/uploads/")) {
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
}
await deleteUploadedImages(product)
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
} catch (error) {
@@ -283,10 +400,9 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex
}
})
app.use((error, req, res, next) => {
if (error instanceof multer.MulterError) {
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." })
res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
return
}
@@ -296,28 +412,20 @@ app.use((error, req, res, next) => {
}
console.error(error)
res.status(500).json({ message: "Terjadi kesalahan pada server." })
res.status(500).json({
message: "Terjadi kesalahan pada server.",
detail: process.env.NODE_ENV === "production" ? undefined : error.message
})
})
app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${port}`)
})
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)
}
}
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)
})