feat: bootstrap backend API, schema and forgejo task issues
This commit is contained in:
parent
77e837cc25
commit
09ea388190
15 changed files with 1557 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
backend/node_modules/
|
||||
backend/.env
|
||||
24
README.md
24
README.md
|
|
@ -1,2 +1,26 @@
|
|||
# helpyourneighbour
|
||||
|
||||
Erster funktionaler Backend-Stand für die Vision:
|
||||
|
||||
- Nutzerregistrierung und Login (`/auth/register`, `/auth/login`)
|
||||
- Hilfeanfragen erstellen/listen (`/requests`)
|
||||
- Angebote + Gegenangebote + Deal-Annahme (`/offers/...`)
|
||||
- Bewertungsgrundlage mit 2-14 Tage Prompt-Fenster (`/reviews/:dealId`)
|
||||
- Datenmodell inkl. postalischer Adress-Verifikation (`backend/sql/schema.sql`)
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run db:init
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Forgejo Tasks
|
||||
|
||||
- #1 Backend Grundgerüst + Auth API
|
||||
- #2 Datenmodell für Request/Offer/Negotiation/Deal
|
||||
- #3 Bewertungssystem 2-14 Tage Verzögerung
|
||||
- #4 Adressänderung nur per Briefbestätigung
|
||||
|
|
|
|||
7
backend/.env.example
Normal file
7
backend/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
PORT=3000
|
||||
DB_HOST=80.74.142.125
|
||||
DB_PORT=3306
|
||||
DB_NAME=helpyourneighbour
|
||||
DB_USER=helpyourneighbour
|
||||
DB_PASSWORD=change-me
|
||||
JWT_SECRET=change-me-super-secret
|
||||
1114
backend/package-lock.json
generated
Normal file
1114
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
backend/package.json
Normal file
25
backend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"db:init": "node src/db/init.js",
|
||||
"db:seed": "node src/db/seed.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.18.2",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
101
backend/sql/schema.sql
Normal file
101
backend/sql/schema.sql
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(120) NOT NULL,
|
||||
phone_encrypted TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS help_requests (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
requester_id BIGINT NOT NULL,
|
||||
title VARCHAR(180) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
value_chf DECIMAL(10,2) NOT NULL,
|
||||
status ENUM('open','negotiating','agreed','completed','cancelled') DEFAULT 'open',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS offers (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
request_id BIGINT NOT NULL,
|
||||
helper_id BIGINT NOT NULL,
|
||||
amount_chf DECIMAL(10,2) NOT NULL,
|
||||
message TEXT NULL,
|
||||
status ENUM('pending','countered','accepted','rejected') DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (request_id) REFERENCES help_requests(id),
|
||||
FOREIGN KEY (helper_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiations (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
offer_id BIGINT NOT NULL,
|
||||
sender_id BIGINT NOT NULL,
|
||||
amount_chf DECIMAL(10,2) NOT NULL,
|
||||
message TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id),
|
||||
FOREIGN KEY (sender_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deals (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
request_id BIGINT NOT NULL,
|
||||
offer_id BIGINT NOT NULL,
|
||||
agreed_amount_chf DECIMAL(10,2) NOT NULL,
|
||||
agreed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (request_id) REFERENCES help_requests(id),
|
||||
FOREIGN KEY (offer_id) REFERENCES offers(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_exchange_requests (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
deal_id BIGINT NOT NULL,
|
||||
requester_id BIGINT NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
accepted BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (deal_id) REFERENCES deals(id),
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id),
|
||||
FOREIGN KEY (target_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
address_encrypted TEXT NOT NULL,
|
||||
postal_verified_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address_change_requests (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
new_address_encrypted TEXT NOT NULL,
|
||||
verification_code_hash VARCHAR(255) NOT NULL,
|
||||
status ENUM('pending_letter','verified','expired','rejected') DEFAULT 'pending_letter',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
verified_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
deal_id BIGINT NOT NULL,
|
||||
reviewer_id BIGINT NOT NULL,
|
||||
reviewee_id BIGINT NOT NULL,
|
||||
rating TINYINT NOT NULL,
|
||||
comment TEXT NULL,
|
||||
earliest_prompt_at TIMESTAMP NOT NULL,
|
||||
latest_prompt_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (deal_id) REFERENCES deals(id),
|
||||
FOREIGN KEY (reviewer_id) REFERENCES users(id),
|
||||
FOREIGN KEY (reviewee_id) REFERENCES users(id),
|
||||
CHECK (rating BETWEEN 1 AND 5)
|
||||
);
|
||||
15
backend/src/db/connection.js
Normal file
15
backend/src/db/connection.js
Normal 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
29
backend/src/db/init.js
Normal 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
20
backend/src/db/seed.js
Normal 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);
|
||||
});
|
||||
15
backend/src/middleware/auth.js
Normal file
15
backend/src/middleware/auth.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
53
backend/src/routes/auth.js
Normal file
53
backend/src/routes/auth.js
Normal 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;
|
||||
36
backend/src/routes/helpRequests.js
Normal file
36
backend/src/routes/helpRequests.js
Normal 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;
|
||||
64
backend/src/routes/offers.js
Normal file
64
backend/src/routes/offers.js
Normal 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;
|
||||
29
backend/src/routes/reviews.js
Normal file
29
backend/src/routes/reviews.js
Normal 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
23
backend/src/server.js
Normal 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}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue