Adds Docker setup and basic API endpoints
WHAT: Introduces Dockerfiles for development and production, Docker Compose configurations, a Makefile for common tasks, shell scripts for environment setup/teardown, a basic Express API with SQLite integration. WHY: Enables easy setup and deployment of the application using Docker. Provides basic API endpoints for managing purchase items. HOW: - Creates `docker-compose.dev.yml` and `docker-compose.prod.yml` to define services and volumes. - Introduces `Dockerfile.dev` and `Dockerfile.prod` to build container images with necessary dependencies. - Adds `Makefile` with commands for building, running, and managing the application. - Implements shell scripts for simplified Docker environment management. - Sets up Express API with endpoints for CRUD operations on purchase items, using SQLite as the database. - Uses `better-sqlite3` to connect and interact with the SQLite database.
This commit is contained in:
parent
3b638a9509
commit
4557728932
20 changed files with 1955 additions and 20 deletions
195
server/src/index.ts
Normal file
195
server/src/index.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import Database from 'better-sqlite3';
|
||||
import { z } from 'zod';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const dbPath = process.env.DB_PATH || './data/purchase.db';
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Ensure table exists
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS purchase_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
product_code TEXT,
|
||||
product_name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
original_price TEXT NOT NULL,
|
||||
original_currency TEXT NOT NULL CHECK(original_currency IN ('SGD','USD','RMB')),
|
||||
importance TEXT NOT NULL CHECK(importance IN ('必须','必须的必','可买','不急')),
|
||||
platform TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
category TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
batch TEXT NOT NULL,
|
||||
purchased INTEGER NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
purchase_link TEXT
|
||||
)`);
|
||||
|
||||
const StoreItemSchema = z.object({
|
||||
id: z.string(),
|
||||
productCode: z.string(),
|
||||
productName: z.string(),
|
||||
price: z.number(),
|
||||
originalPrice: z.string(),
|
||||
originalCurrency: z.enum(['SGD','USD','RMB']),
|
||||
importance: z.enum(['必须','必须的必','可买','不急']),
|
||||
platform: z.string(),
|
||||
notes: z.string(),
|
||||
category: z.string(),
|
||||
site: z.string(),
|
||||
batch: z.string(),
|
||||
purchased: z.boolean(),
|
||||
weight: z.number(),
|
||||
purchaseLink: z.string().optional()
|
||||
});
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
type DbRow = {
|
||||
id: string;
|
||||
product_code: string | null;
|
||||
product_name: string;
|
||||
price: number;
|
||||
original_price: string;
|
||||
original_currency: 'SGD'|'USD'|'RMB';
|
||||
category: string;
|
||||
importance: string;
|
||||
platform: string;
|
||||
notes: string | null;
|
||||
site: string;
|
||||
batch: string;
|
||||
purchased: number; // 0/1
|
||||
weight: number;
|
||||
purchase_link: string | null;
|
||||
};
|
||||
|
||||
app.get('/api/items', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM purchase_items ORDER BY rowid DESC').all() as DbRow[];
|
||||
const items = rows.map((r: DbRow) => ({
|
||||
id: r.id,
|
||||
productCode: r.product_code ?? '',
|
||||
productName: r.product_name,
|
||||
price: r.price,
|
||||
originalPrice: r.original_price,
|
||||
originalCurrency: r.original_currency,
|
||||
importance: r.importance as '必须'|'必须的必'|'可买'|'不急',
|
||||
platform: r.platform,
|
||||
notes: r.notes ?? '',
|
||||
category: r.category,
|
||||
site: r.site,
|
||||
batch: r.batch,
|
||||
purchased: !!r.purchased,
|
||||
weight: r.weight,
|
||||
purchaseLink: r.purchase_link ?? ''
|
||||
}));
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
app.post('/api/items', (req, res) => {
|
||||
const parse = StoreItemSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
return res.status(400).json({ error: parse.error.message });
|
||||
}
|
||||
const i = parse.data;
|
||||
db.prepare(`INSERT INTO purchase_items (
|
||||
id,product_code,product_name,price,original_price,original_currency,importance,platform,notes,category,site,batch,purchased,weight,purchase_link
|
||||
) VALUES (@id,@product_code,@product_name,@price,@original_price,@original_currency,@importance,@platform,@notes,@category,@site,@batch,@purchased,@weight,@purchase_link)`)
|
||||
.run({
|
||||
id: i.id,
|
||||
product_code: i.productCode,
|
||||
product_name: i.productName,
|
||||
price: i.price,
|
||||
original_price: i.originalPrice,
|
||||
original_currency: i.originalCurrency,
|
||||
importance: i.importance,
|
||||
platform: i.platform,
|
||||
notes: i.notes ?? '',
|
||||
category: i.category,
|
||||
site: i.site,
|
||||
batch: i.batch,
|
||||
purchased: i.purchased ? 1 : 0,
|
||||
weight: i.weight,
|
||||
purchase_link: i.purchaseLink ?? ''
|
||||
});
|
||||
res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/items/:id', (req, res) => {
|
||||
const parse = StoreItemSchema.safeParse({ ...req.body, id: req.params.id });
|
||||
if (!parse.success) {
|
||||
return res.status(400).json({ error: parse.error.message });
|
||||
}
|
||||
const i = parse.data;
|
||||
const changes = db.prepare(`UPDATE purchase_items SET
|
||||
product_code=@product_code,
|
||||
product_name=@product_name,
|
||||
price=@price,
|
||||
original_price=@original_price,
|
||||
original_currency=@original_currency,
|
||||
importance=@importance,
|
||||
platform=@platform,
|
||||
notes=@notes,
|
||||
category=@category,
|
||||
site=@site,
|
||||
batch=@batch,
|
||||
purchased=@purchased,
|
||||
weight=@weight,
|
||||
purchase_link=@purchase_link
|
||||
WHERE id=@id`).run({
|
||||
id: i.id,
|
||||
product_code: i.productCode,
|
||||
product_name: i.productName,
|
||||
price: i.price,
|
||||
original_price: i.originalPrice,
|
||||
original_currency: i.originalCurrency,
|
||||
importance: i.importance,
|
||||
platform: i.platform,
|
||||
notes: i.notes ?? '',
|
||||
category: i.category,
|
||||
site: i.site,
|
||||
batch: i.batch,
|
||||
purchased: i.purchased ? 1 : 0,
|
||||
weight: i.weight,
|
||||
purchase_link: i.purchaseLink ?? ''
|
||||
});
|
||||
if (changes.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/items/:id', (req, res) => {
|
||||
const info = db.prepare('DELETE FROM purchase_items WHERE id = ?').run(req.params.id);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Quick toggle purchased
|
||||
app.patch('/api/items/:id/purchased', (req, res) => {
|
||||
const purchased = !!req.body?.purchased;
|
||||
const info = db.prepare('UPDATE purchase_items SET purchased = ? WHERE id = ?').run(purchased ? 1 : 0, req.params.id);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
// Serve prebuilt frontend when present (production)
|
||||
const frontendDir = path.resolve(process.cwd(), 'frontend');
|
||||
if (fs.existsSync(frontendDir)) {
|
||||
app.use(express.static(frontendDir));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(frontendDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`[server] listening on http://0.0.0.0:${port}`);
|
||||
});
|
||||
|
||||
|
||||
23
server/src/migrate.ts
Normal file
23
server/src/migrate.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Database from 'better-sqlite3';
|
||||
|
||||
const dbPath = process.env.DB_PATH || './data/purchase.db';
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS purchase_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_name TEXT NOT NULL,
|
||||
item_link TEXT,
|
||||
category TEXT NOT NULL,
|
||||
importance TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
batch TEXT,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending','purchased','cancelled')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
)`);
|
||||
|
||||
console.log('Migration complete. DB at', dbPath);
|
||||
|
||||
|
||||
3
server/src/types.d.ts
vendored
Normal file
3
server/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
declare module 'better-sqlite3';
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue