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:
64
src/db/connection.js
Normal file
64
src/db/connection.js
Normal 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 };
|
||||
140
src/db/migrations/001_initial_schema.sql
Normal file
140
src/db/migrations/001_initial_schema.sql
Normal 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
29
src/middleware/auth.js
Normal 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
102
src/routes/auth.js
Normal 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
148
src/routes/craftsmen.js
Normal 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
101
src/routes/orders.js
Normal 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
155
src/routes/products.js
Normal 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;
|
||||
Reference in New Issue
Block a user