feat: bootstrap backend API, schema and forgejo task issues

This commit is contained in:
openclaw 2026-03-04 16:03:04 +00:00
parent 77e837cc25
commit 09ea388190
15 changed files with 1557 additions and 0 deletions

View file

@ -0,0 +1,15 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
export const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});

29
backend/src/db/init.js Normal file
View file

@ -0,0 +1,29 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { pool } from './connection.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const run = async () => {
const schemaPath = path.resolve(__dirname, '../../sql/schema.sql');
const sql = await fs.readFile(schemaPath, 'utf8');
const statements = sql
.split(';')
.map((s) => s.trim())
.filter(Boolean);
for (const statement of statements) {
await pool.query(statement);
}
console.log(`Applied ${statements.length} SQL statements.`);
await pool.end();
};
run().catch(async (err) => {
console.error('Database init failed:', err.message);
await pool.end();
process.exit(1);
});

20
backend/src/db/seed.js Normal file
View file

@ -0,0 +1,20 @@
import bcrypt from 'bcryptjs';
import { pool } from './connection.js';
const run = async () => {
const hash = await bcrypt.hash('changeme123', 12);
await pool.query(
`INSERT INTO users (email, password_hash, display_name) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name)`,
['demo@helpyourneighbour.ch', hash, 'Demo User']
);
console.log('Seed complete.');
await pool.end();
};
run().catch(async (err) => {
console.error('Seed failed:', err.message);
await pool.end();
process.exit(1);
});

View file

@ -0,0 +1,15 @@
import jwt from 'jsonwebtoken';
export const requireAuth = (req, res, next) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};

View file

@ -0,0 +1,53 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { pool } from '../db/connection.js';
const router = Router();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(2).max(120)
});
router.post('/register', async (req, res) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { email, password, displayName } = parsed.data;
const passwordHash = await bcrypt.hash(password, 12);
try {
const [result] = await pool.query(
'INSERT INTO users (email, password_hash, display_name) VALUES (?, ?, ?)',
[email, passwordHash, displayName]
);
const token = jwt.sign({ userId: result.insertId, email }, process.env.JWT_SECRET, { expiresIn: '7d' });
return res.status(201).json({ token });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Email already exists' });
return res.status(500).json({ error: 'Registration failed' });
}
});
router.post('/login', async (req, res) => {
const parsed = z.object({ email: z.string().email(), password: z.string().min(1) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { email, password } = parsed.data;
const [rows] = await pool.query('SELECT id, email, password_hash FROM users WHERE email = ? LIMIT 1', [email]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ userId: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: '7d' });
return res.json({ token });
});
export default router;

View file

@ -0,0 +1,36 @@
import { Router } from 'express';
import { z } from 'zod';
import { pool } from '../db/connection.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.get('/', async (_req, res) => {
const [rows] = await pool.query(
`SELECT hr.id, hr.title, hr.description, hr.value_chf, hr.status, hr.created_at, u.display_name requester_name
FROM help_requests hr
JOIN users u ON u.id = hr.requester_id
ORDER BY hr.created_at DESC`
);
res.json(rows);
});
router.post('/', requireAuth, async (req, res) => {
const parsed = z.object({
title: z.string().min(3).max(180),
description: z.string().min(5),
valueChf: z.number().positive()
}).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { title, description, valueChf } = parsed.data;
const [result] = await pool.query(
'INSERT INTO help_requests (requester_id, title, description, value_chf) VALUES (?, ?, ?, ?)',
[req.user.userId, title, description, valueChf]
);
res.status(201).json({ id: result.insertId });
});
export default router;

View file

@ -0,0 +1,64 @@
import { Router } from 'express';
import { z } from 'zod';
import { pool } from '../db/connection.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.post('/:requestId', requireAuth, async (req, res) => {
const requestId = Number(req.params.requestId);
const parsed = z.object({ amountChf: z.number().positive(), message: z.string().max(2000).optional() }).safeParse(req.body);
if (!parsed.success || Number.isNaN(requestId)) return res.status(400).json({ error: 'Invalid payload' });
const { amountChf, message } = parsed.data;
const [result] = await pool.query(
`INSERT INTO offers (request_id, helper_id, amount_chf, message)
VALUES (?, ?, ?, ?)`,
[requestId, req.user.userId, amountChf, message || null]
);
await pool.query('UPDATE help_requests SET status = ? WHERE id = ?', ['negotiating', requestId]);
res.status(201).json({ id: result.insertId });
});
router.post('/negotiation/:offerId', requireAuth, async (req, res) => {
const offerId = Number(req.params.offerId);
const parsed = z.object({ amountChf: z.number().positive(), message: z.string().max(2000).optional() }).safeParse(req.body);
if (!parsed.success || Number.isNaN(offerId)) return res.status(400).json({ error: 'Invalid payload' });
const { amountChf, message } = parsed.data;
const [result] = await pool.query(
`INSERT INTO negotiations (offer_id, sender_id, amount_chf, message)
VALUES (?, ?, ?, ?)`,
[offerId, req.user.userId, amountChf, message || null]
);
await pool.query('UPDATE offers SET status = ? WHERE id = ?', ['countered', offerId]);
res.status(201).json({ id: result.insertId });
});
router.post('/accept/:offerId', requireAuth, async (req, res) => {
const offerId = Number(req.params.offerId);
if (Number.isNaN(offerId)) return res.status(400).json({ error: 'Invalid offerId' });
const [offers] = await pool.query(
'SELECT id, request_id, amount_chf FROM offers WHERE id = ? LIMIT 1',
[offerId]
);
const offer = offers[0];
if (!offer) return res.status(404).json({ error: 'Offer not found' });
await pool.query('UPDATE offers SET status = ? WHERE id = ?', ['accepted', offerId]);
await pool.query('UPDATE help_requests SET status = ? WHERE id = ?', ['agreed', offer.request_id]);
const [dealResult] = await pool.query(
'INSERT INTO deals (request_id, offer_id, agreed_amount_chf) VALUES (?, ?, ?)',
[offer.request_id, offer.id, offer.amount_chf]
);
res.status(201).json({ dealId: dealResult.insertId });
});
export default router;

View file

@ -0,0 +1,29 @@
import { Router } from 'express';
import { z } from 'zod';
import { pool } from '../db/connection.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.post('/:dealId', requireAuth, async (req, res) => {
const dealId = Number(req.params.dealId);
const parsed = z.object({ revieweeId: z.number().int().positive(), rating: z.number().int().min(1).max(5), comment: z.string().max(2000).optional() }).safeParse(req.body);
if (!parsed.success || Number.isNaN(dealId)) return res.status(400).json({ error: 'Invalid payload' });
const now = new Date();
const earliest = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
const latest = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const { revieweeId, rating, comment } = parsed.data;
const [result] = await pool.query(
`INSERT INTO reviews (deal_id, reviewer_id, reviewee_id, rating, comment, earliest_prompt_at, latest_prompt_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[dealId, req.user.userId, revieweeId, rating, comment || null, earliest, latest]
);
res.status(201).json({ id: result.insertId });
});
export default router;

23
backend/src/server.js Normal file
View file

@ -0,0 +1,23 @@
import express from 'express';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import helpRequestRoutes from './routes/helpRequests.js';
import offerRoutes from './routes/offers.js';
import reviewRoutes from './routes/reviews.js';
dotenv.config();
const app = express();
app.use(express.json());
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
app.use('/auth', authRoutes);
app.use('/requests', helpRequestRoutes);
app.use('/offers', offerRoutes);
app.use('/reviews', reviewRoutes);
const port = Number(process.env.PORT || 3000);
app.listen(port, () => {
console.log(`helpyourneighbour backend listening on ${port}`);
});