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:
Chenwei Jiang 2025-08-21 14:46:11 +08:00
parent 3b638a9509
commit 4557728932
Signed by: cheverjohn
GPG key ID: ADC4815BFE960182
20 changed files with 1955 additions and 20 deletions

36
server/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "dm-purchase-server",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "nodemon --watch src --exec ts-node src/index.ts",
"build": "tsc -b",
"start": "node dist/index.js",
"migrate": "ts-node src/migrate.ts"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.14.1",
"nodemon": "^3.1.9",
"ts-node": "^10.9.2",
"typescript": "~5.7.2"
},
"pnpm": {
"allowedScripts": {
"better-sqlite3": true
}
}
}

1335
server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

195
server/src/index.ts Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
declare module 'better-sqlite3';

16
server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2022",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"resolveJsonModule": true,
"strict": true
},
"include": ["src"]
}