feat: add backend infrastructure - Express server, auth, CRUD APIs, DB migrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:16:11 +00:00
parent 928e1125bf
commit 6707c44d31
9 changed files with 821 additions and 0 deletions

24
package.json Normal file
View File

@@ -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"
}
}

58
server.js Normal file
View File

@@ -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();

64
src/db/connection.js Normal file
View File

@@ -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 };

View File

@@ -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);

29
src/middleware/auth.js Normal file
View File

@@ -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 };

102
src/routes/auth.js Normal file
View File

@@ -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;

148
src/routes/craftsmen.js Normal file
View File

@@ -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;

101
src/routes/orders.js Normal file
View File

@@ -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;

155
src/routes/products.js Normal file
View File

@@ -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;