From 6707c44d315f4b6e822bec78156d9a15c855e2fe Mon Sep 17 00:00:00 2001 From: tester Date: Sat, 21 Feb 2026 18:16:11 +0000 Subject: [PATCH] feat: add backend infrastructure - Express server, auth, CRUD APIs, DB migrations Co-Authored-By: Claude Sonnet 4.6 --- package.json | 24 ++++ server.js | 58 +++++++++ src/db/connection.js | 64 ++++++++++ src/db/migrations/001_initial_schema.sql | 140 ++++++++++++++++++++ src/middleware/auth.js | 29 +++++ src/routes/auth.js | 102 +++++++++++++++ src/routes/craftsmen.js | 148 ++++++++++++++++++++++ src/routes/orders.js | 101 +++++++++++++++ src/routes/products.js | 155 +++++++++++++++++++++++ 9 files changed, 821 insertions(+) create mode 100644 package.json create mode 100644 server.js create mode 100644 src/db/connection.js create mode 100644 src/db/migrations/001_initial_schema.sql create mode 100644 src/middleware/auth.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/craftsmen.js create mode 100644 src/routes/orders.js create mode 100644 src/routes/products.js diff --git a/package.json b/package.json new file mode 100644 index 0000000..6925df2 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "shokuninmarche", + "version": "1.0.0", + "description": "職人マルシェ - Japanese Craft Marketplace", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "migrate": "node src/db/migrate.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.11.3", + "uuid": "^9.0.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..ebb7853 --- /dev/null +++ b/server.js @@ -0,0 +1,58 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Security middleware +app.use(helmet({ contentSecurityPolicy: false })); +app.use(compression()); +app.use(cors({ + origin: process.env.FRONTEND_URL || true, + credentials: true +})); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// Database connection +const { connectDB } = require('./src/db/connection'); + +// Routes +const authRoutes = require('./src/routes/auth'); +const craftsmenRoutes = require('./src/routes/craftsmen'); +const productsRoutes = require('./src/routes/products'); +const ordersRoutes = require('./src/routes/orders'); + +app.use('/api/auth', authRoutes); +app.use('/api/craftsmen', craftsmenRoutes); +app.use('/api/products', productsRoutes); +app.use('/api/orders', ordersRoutes); + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', service: 'shokuninmarche-api', timestamp: new Date().toISOString() }); +}); + +// Serve React frontend in production +app.use(express.static(path.join(__dirname, 'client/dist'))); +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'client/dist', 'index.html')); +}); + +// Start server +async function start() { + try { + await connectDB(); + app.listen(PORT, () => { + console.log(`🚀 shokuninmarche server running on port ${PORT}`); + }); + } catch (err) { + console.error('Failed to start server:', err); + process.exit(1); + } +} + +start(); diff --git a/src/db/connection.js b/src/db/connection.js new file mode 100644 index 0000000..455b08e --- /dev/null +++ b/src/db/connection.js @@ -0,0 +1,64 @@ +const { Pool } = require('pg'); + +let pool; + +async function connectDB() { + pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' && process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost') && !process.env.DATABASE_URL.includes('postgres:') + ? { rejectUnauthorized: false } + : false + }); + + // Test connection + const client = await pool.connect(); + console.log('✅ PostgreSQL connected'); + + // Run migrations + await runMigrations(client); + client.release(); + + return pool; +} + +async function runMigrations(client) { + // Create migrations tracking table + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + applied_at TIMESTAMP DEFAULT NOW() + ) + `); + + const fs = require('fs'); + const path = require('path'); + const migrationsDir = path.join(__dirname, 'migrations'); + + if (!fs.existsSync(migrationsDir)) return; + + const files = fs.readdirSync(migrationsDir).sort(); + for (const file of files) { + if (!file.endsWith('.sql')) continue; + + const { rows } = await client.query( + 'SELECT id FROM schema_migrations WHERE filename = $1', + [file] + ); + if (rows.length > 0) continue; + + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'); + await client.query(sql); + await client.query( + 'INSERT INTO schema_migrations (filename) VALUES ($1)', + [file] + ); + console.log(`✅ Migration applied: ${file}`); + } +} + +function getPool() { + return pool; +} + +module.exports = { connectDB, getPool }; diff --git a/src/db/migrations/001_initial_schema.sql b/src/db/migrations/001_initial_schema.sql new file mode 100644 index 0000000..76e604e --- /dev/null +++ b/src/db/migrations/001_initial_schema.sql @@ -0,0 +1,140 @@ +-- Users table (buyers, craftsmen, admins) +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'buyer', -- buyer, craftsman, admin + first_name VARCHAR(100), + last_name VARCHAR(100), + display_name VARCHAR(200), + avatar_url TEXT, + phone VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + email_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Craftsmen profiles +CREATE TABLE IF NOT EXISTS craftsmen ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shop_name VARCHAR(255) NOT NULL, + bio TEXT, + story TEXT, + profile_image_url TEXT, + banner_image_url TEXT, + location VARCHAR(255), + prefecture VARCHAR(100), + craft_region VARCHAR(255), + regional_designation VARCHAR(255), + guild_association VARCHAR(255), + meti_certified BOOLEAN DEFAULT FALSE, + meti_certification_number VARCHAR(100), + meti_craft_category VARCHAR(255), + years_of_practice INTEGER, + apprenticeship_lineage TEXT, + primary_materials TEXT, + workshop_size VARCHAR(50), -- solo / small_atelier / cooperative + languages_spoken TEXT[], + production_method VARCHAR(50), -- fully_handmade / partially_machine_assisted + craft_category VARCHAR(50), + social_links JSONB, + rating DECIMAL(3,2) DEFAULT 0, + total_reviews INTEGER DEFAULT 0, + total_sales INTEGER DEFAULT 0, + is_verified BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Products table +CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + craftsman_id UUID NOT NULL REFERENCES craftsmen(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + name_ja VARCHAR(255), + description TEXT, + description_ja TEXT, + price DECIMAL(12,2) NOT NULL, + currency VARCHAR(10) DEFAULT 'JPY', + stock_quantity INTEGER DEFAULT 0, + craft_category VARCHAR(50), + craft_region VARCHAR(255), + food_subcategory VARCHAR(50), -- fermented / dried_seafood / confectionery / produce / condiments + images TEXT[], + ar_model_url TEXT, + ar_eligible BOOLEAN DEFAULT TRUE, + ar_ineligible_reason VARCHAR(100), -- devotional / ceremonial / other + is_active BOOLEAN DEFAULT TRUE, + is_featured BOOLEAN DEFAULT FALSE, + meti_certified BOOLEAN DEFAULT FALSE, + weight_grams INTEGER, + dimensions JSONB, + tags TEXT[], + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Orders table +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + buyer_id UUID NOT NULL REFERENCES users(id), + status VARCHAR(50) DEFAULT 'pending', -- pending, confirmed, processing, shipped, delivered, cancelled + total_amount DECIMAL(12,2) NOT NULL, + currency VARCHAR(10) DEFAULT 'JPY', + shipping_address JSONB, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Order items +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES products(id), + craftsman_id UUID NOT NULL REFERENCES craftsmen(id), + quantity INTEGER NOT NULL DEFAULT 1, + unit_price DECIMAL(12,2) NOT NULL, + total_price DECIMAL(12,2) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Reviews table +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + buyer_id UUID NOT NULL REFERENCES users(id), + craftsman_id UUID NOT NULL REFERENCES craftsmen(id), + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Subscriptions table +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tier VARCHAR(50) NOT NULL, -- discovery / artisan / tradition + status VARCHAR(50) DEFAULT 'active', -- active, paused, cancelled + price_jpy INTEGER NOT NULL, -- 3500, 7500, or 15000 + started_at TIMESTAMP DEFAULT NOW(), + next_billing_date TIMESTAMP, + cancelled_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_craftsmen_user_id ON craftsmen(user_id); +CREATE INDEX IF NOT EXISTS idx_craftsmen_craft_category ON craftsmen(craft_category); +CREATE INDEX IF NOT EXISTS idx_craftsmen_meti_certified ON craftsmen(meti_certified); +CREATE INDEX IF NOT EXISTS idx_products_craftsman_id ON products(craftsman_id); +CREATE INDEX IF NOT EXISTS idx_products_craft_category ON products(craft_category); +CREATE INDEX IF NOT EXISTS idx_products_is_active ON products(is_active); +CREATE INDEX IF NOT EXISTS idx_orders_buyer_id ON orders(buyer_id); +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); +CREATE INDEX IF NOT EXISTS idx_reviews_product_id ON reviews(product_id); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..80cb57e --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,29 @@ +const jwt = require('jsonwebtoken'); + +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + jwt.verify(token, process.env.JWT_SECRET || 'changeme', (err, user) => { + if (err) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + req.user = user; + next(); + }); +} + +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + next(); + }; +} + +module.exports = { authenticateToken, requireRole }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..dad3cb6 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,102 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { getPool } = require('../db/connection'); +const { authenticateToken } = require('../middleware/auth'); + +// Register +router.post('/register', async (req, res) => { + try { + const { email, password, role = 'buyer', first_name, last_name, display_name } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }); + } + + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + + const pool = getPool(); + const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]); + if (existing.rows.length > 0) { + return res.status(409).json({ error: 'Email already registered' }); + } + + const password_hash = await bcrypt.hash(password, 12); + const validRole = ['buyer', 'craftsman', 'admin'].includes(role) ? role : 'buyer'; + + const { rows } = await pool.query( + `INSERT INTO users (email, password_hash, role, first_name, last_name, display_name) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, role, first_name, last_name, display_name, created_at`, + [email, password_hash, validRole, first_name || null, last_name || null, display_name || null] + ); + + const user = rows[0]; + const token = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET || 'changeme', + { expiresIn: '7d' } + ); + + res.status(201).json({ user, token }); + } catch (err) { + console.error('Register error:', err); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +// Login +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }); + } + + const pool = getPool(); + const { rows } = await pool.query('SELECT * FROM users WHERE email = $1 AND is_active = true', [email]); + + if (rows.length === 0) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const user = rows[0]; + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const token = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET || 'changeme', + { expiresIn: '7d' } + ); + + const { password_hash, ...userWithoutPassword } = user; + res.json({ user: userWithoutPassword, token }); + } catch (err) { + console.error('Login error:', err); + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Get current user +router.get('/me', authenticateToken, async (req, res) => { + try { + const pool = getPool(); + const { rows } = await pool.query( + 'SELECT id, email, role, first_name, last_name, display_name, avatar_url, created_at FROM users WHERE id = $1', + [req.user.id] + ); + + if (rows.length === 0) return res.status(404).json({ error: 'User not found' }); + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: 'Failed to get user' }); + } +}); + +module.exports = router; diff --git a/src/routes/craftsmen.js b/src/routes/craftsmen.js new file mode 100644 index 0000000..b77c449 --- /dev/null +++ b/src/routes/craftsmen.js @@ -0,0 +1,148 @@ +const express = require('express'); +const router = express.Router(); +const { getPool } = require('../db/connection'); +const { authenticateToken, requireRole } = require('../middleware/auth'); + +// List craftsmen +router.get('/', async (req, res) => { + try { + const pool = getPool(); + const { craft_category, meti_certified, page = 1, limit = 20 } = req.query; + const offset = (page - 1) * limit; + + let query = `SELECT c.*, u.email, u.display_name as user_display_name + FROM craftsmen c JOIN users u ON c.user_id = u.id + WHERE c.is_active = true`; + const params = []; + + if (craft_category) { + params.push(craft_category); + query += ` AND c.craft_category = $${params.length}`; + } + if (meti_certified === 'true') { + query += ` AND c.meti_certified = true`; + } + + query += ` ORDER BY c.meti_certified DESC, c.rating DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + params.push(limit, offset); + + const { rows } = await pool.query(query, params); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to fetch craftsmen' }); + } +}); + +// Get craftsman by ID +router.get('/:id', async (req, res) => { + try { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT c.*, u.email, u.display_name as user_display_name + FROM craftsmen c JOIN users u ON c.user_id = u.id + WHERE c.id = $1 AND c.is_active = true`, + [req.params.id] + ); + + if (rows.length === 0) return res.status(404).json({ error: 'Craftsman not found' }); + + // Get their products + const products = await pool.query( + 'SELECT * FROM products WHERE craftsman_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 10', + [req.params.id] + ); + + res.json({ ...rows[0], products: products.rows }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch craftsman' }); + } +}); + +// Create craftsman profile (for craftsman users) +router.post('/', authenticateToken, requireRole('craftsman', 'admin'), async (req, res) => { + try { + const pool = getPool(); + const { + shop_name, bio, story, location, prefecture, craft_region, + regional_designation, guild_association, meti_certified, + meti_certification_number, meti_craft_category, years_of_practice, + apprenticeship_lineage, primary_materials, workshop_size, + languages_spoken, production_method, craft_category, social_links + } = req.body; + + if (!shop_name) return res.status(400).json({ error: 'shop_name required' }); + + const { rows } = await pool.query( + `INSERT INTO craftsmen ( + user_id, shop_name, bio, story, location, prefecture, craft_region, + regional_designation, guild_association, meti_certified, meti_certification_number, + meti_craft_category, years_of_practice, apprenticeship_lineage, primary_materials, + workshop_size, languages_spoken, production_method, craft_category, social_links + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) + RETURNING *`, + [ + req.user.id, shop_name, bio, story, location, prefecture, craft_region, + regional_designation, guild_association, meti_certified || false, + meti_certification_number, meti_craft_category, years_of_practice, + apprenticeship_lineage, primary_materials, workshop_size, + languages_spoken, production_method, craft_category, + social_links ? JSON.stringify(social_links) : null + ] + ); + + res.status(201).json(rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to create craftsman profile' }); + } +}); + +// Update craftsman profile +router.put('/:id', authenticateToken, async (req, res) => { + try { + const pool = getPool(); + // Check ownership or admin + const { rows: existing } = await pool.query( + 'SELECT * FROM craftsmen WHERE id = $1', [req.params.id] + ); + if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); + if (existing[0].user_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const { + shop_name, bio, story, location, prefecture, craft_region, + regional_designation, guild_association, meti_certified, + years_of_practice, workshop_size, production_method, craft_category, social_links + } = req.body; + + const { rows } = await pool.query( + `UPDATE craftsmen SET + shop_name = COALESCE($1, shop_name), + bio = COALESCE($2, bio), + story = COALESCE($3, story), + location = COALESCE($4, location), + prefecture = COALESCE($5, prefecture), + craft_region = COALESCE($6, craft_region), + regional_designation = COALESCE($7, regional_designation), + guild_association = COALESCE($8, guild_association), + meti_certified = COALESCE($9, meti_certified), + years_of_practice = COALESCE($10, years_of_practice), + workshop_size = COALESCE($11, workshop_size), + production_method = COALESCE($12, production_method), + craft_category = COALESCE($13, craft_category), + updated_at = NOW() + WHERE id = $14 RETURNING *`, + [shop_name, bio, story, location, prefecture, craft_region, + regional_designation, guild_association, meti_certified, + years_of_practice, workshop_size, production_method, craft_category, req.params.id] + ); + + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: 'Failed to update craftsman' }); + } +}); + +module.exports = router; diff --git a/src/routes/orders.js b/src/routes/orders.js new file mode 100644 index 0000000..c1076c4 --- /dev/null +++ b/src/routes/orders.js @@ -0,0 +1,101 @@ +const express = require('express'); +const router = express.Router(); +const { getPool } = require('../db/connection'); +const { authenticateToken } = require('../middleware/auth'); + +// Get my orders +router.get('/', authenticateToken, async (req, res) => { + try { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT o.*, json_agg( + json_build_object( + 'id', oi.id, + 'product_id', oi.product_id, + 'quantity', oi.quantity, + 'unit_price', oi.unit_price, + 'total_price', oi.total_price, + 'product_name', p.name + ) + ) as items + FROM orders o + LEFT JOIN order_items oi ON oi.order_id = o.id + LEFT JOIN products p ON p.id = oi.product_id + WHERE o.buyer_id = $1 + GROUP BY o.id + ORDER BY o.created_at DESC`, + [req.user.id] + ); + res.json(rows); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch orders' }); + } +}); + +// Create order +router.post('/', authenticateToken, async (req, res) => { + try { + const pool = getPool(); + const { items, shipping_address, notes } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ error: 'items required' }); + } + + // Calculate total and validate stock + let totalAmount = 0; + const enrichedItems = []; + + for (const item of items) { + const { rows } = await pool.query( + 'SELECT * FROM products WHERE id = $1 AND is_active = true', + [item.product_id] + ); + if (rows.length === 0) return res.status(400).json({ error: `Product ${item.product_id} not found` }); + const product = rows[0]; + if (product.stock_quantity < item.quantity) { + return res.status(400).json({ error: `Insufficient stock for ${product.name}` }); + } + const totalPrice = product.price * item.quantity; + totalAmount += totalPrice; + enrichedItems.push({ ...item, unit_price: product.price, total_price: totalPrice, craftsman_id: product.craftsman_id }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { rows: orderRows } = await client.query( + `INSERT INTO orders (buyer_id, total_amount, shipping_address, notes) + VALUES ($1, $2, $3, $4) RETURNING *`, + [req.user.id, totalAmount, JSON.stringify(shipping_address), notes] + ); + const order = orderRows[0]; + + for (const item of enrichedItems) { + await client.query( + `INSERT INTO order_items (order_id, product_id, craftsman_id, quantity, unit_price, total_price) + VALUES ($1, $2, $3, $4, $5, $6)`, + [order.id, item.product_id, item.craftsman_id, item.quantity, item.unit_price, item.total_price] + ); + await client.query( + 'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2', + [item.quantity, item.product_id] + ); + } + + await client.query('COMMIT'); + res.status(201).json(order); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to create order' }); + } +}); + +module.exports = router; diff --git a/src/routes/products.js b/src/routes/products.js new file mode 100644 index 0000000..eed233b --- /dev/null +++ b/src/routes/products.js @@ -0,0 +1,155 @@ +const express = require('express'); +const router = express.Router(); +const { getPool } = require('../db/connection'); +const { authenticateToken, requireRole } = require('../middleware/auth'); + +const VALID_CATEGORIES = ['ceramics', 'lacquerware', 'textiles', 'woodwork_bamboo', 'washi_calligraphy', 'metalwork', 'food', 'dolls_toys', 'other']; + +// List products +router.get('/', async (req, res) => { + try { + const pool = getPool(); + const { craft_category, craftsman_id, meti_certified, search, page = 1, limit = 20 } = req.query; + const offset = (page - 1) * limit; + + let query = `SELECT p.*, c.shop_name, c.meti_certified as craftsman_meti + FROM products p JOIN craftsmen c ON p.craftsman_id = c.id + WHERE p.is_active = true`; + const params = []; + + if (craft_category) { + params.push(craft_category); + query += ` AND p.craft_category = $${params.length}`; + } + if (craftsman_id) { + params.push(craftsman_id); + query += ` AND p.craftsman_id = $${params.length}`; + } + if (meti_certified === 'true') { + query += ` AND (p.meti_certified = true OR c.meti_certified = true)`; + } + if (search) { + params.push(`%${search}%`); + query += ` AND (p.name ILIKE $${params.length} OR p.name_ja ILIKE $${params.length} OR p.description ILIKE $${params.length})`; + } + + query += ` ORDER BY p.is_featured DESC, c.meti_certified DESC, p.created_at DESC`; + query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; + params.push(limit, offset); + + const { rows } = await pool.query(query, params); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to fetch products' }); + } +}); + +// Get product by ID +router.get('/:id', async (req, res) => { + try { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT p.*, c.shop_name, c.meti_certified as craftsman_meti, c.prefecture, c.craft_region + FROM products p JOIN craftsmen c ON p.craftsman_id = c.id + WHERE p.id = $1 AND p.is_active = true`, + [req.params.id] + ); + + if (rows.length === 0) return res.status(404).json({ error: 'Product not found' }); + + // Get reviews + const reviews = await pool.query( + `SELECT r.*, u.display_name FROM reviews r + JOIN users u ON r.buyer_id = u.id + WHERE r.product_id = $1 ORDER BY r.created_at DESC LIMIT 10`, + [req.params.id] + ); + + res.json({ ...rows[0], reviews: reviews.rows }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch product' }); + } +}); + +// Create product +router.post('/', authenticateToken, requireRole('craftsman', 'admin'), async (req, res) => { + try { + const pool = getPool(); + + // Get craftsman profile for this user + const { rows: craftsmanRows } = await pool.query( + 'SELECT id FROM craftsmen WHERE user_id = $1', [req.user.id] + ); + + const craftsmanId = req.user.role === 'admin' && req.body.craftsman_id + ? req.body.craftsman_id + : craftsmanRows[0]?.id; + + if (!craftsmanId) return res.status(400).json({ error: 'Craftsman profile not found' }); + + const { + name, name_ja, description, description_ja, price, stock_quantity = 0, + craft_category, craft_region, food_subcategory, images, ar_model_url, + ar_eligible = true, ar_ineligible_reason, meti_certified, tags + } = req.body; + + if (!name || !price) return res.status(400).json({ error: 'name and price required' }); + + const { rows } = await pool.query( + `INSERT INTO products ( + craftsman_id, name, name_ja, description, description_ja, price, + stock_quantity, craft_category, craft_region, food_subcategory, + images, ar_model_url, ar_eligible, ar_ineligible_reason, meti_certified, tags + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + RETURNING *`, + [ + craftsmanId, name, name_ja, description, description_ja, price, + stock_quantity, craft_category, craft_region, food_subcategory, + images, ar_model_url, ar_eligible, ar_ineligible_reason, meti_certified || false, tags + ] + ); + + res.status(201).json(rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to create product' }); + } +}); + +// Update product +router.put('/:id', authenticateToken, async (req, res) => { + try { + const pool = getPool(); + const { rows: existing } = await pool.query( + `SELECT p.*, c.user_id FROM products p JOIN craftsmen c ON p.craftsman_id = c.id WHERE p.id = $1`, + [req.params.id] + ); + if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); + if (existing[0].user_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const { name, name_ja, description, price, stock_quantity, craft_category, is_active } = req.body; + + const { rows } = await pool.query( + `UPDATE products SET + name = COALESCE($1, name), + name_ja = COALESCE($2, name_ja), + description = COALESCE($3, description), + price = COALESCE($4, price), + stock_quantity = COALESCE($5, stock_quantity), + craft_category = COALESCE($6, craft_category), + is_active = COALESCE($7, is_active), + updated_at = NOW() + WHERE id = $8 RETURNING *`, + [name, name_ja, description, price, stock_quantity, craft_category, is_active, req.params.id] + ); + + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: 'Failed to update product' }); + } +}); + +module.exports = router;