chore: replace manual SQL migrations with node-pg-migrate
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
FROM node:20-bookworm-slim AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm-slim AS backend-build
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json ./
|
||||
RUN npm install
|
||||
COPY backend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=backend-build /app/backend/package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=backend-build /app/backend/dist ./dist
|
||||
COPY --from=backend-build /app/backend/migrate.js ./migrate.js
|
||||
COPY --from=backend-build /app/backend/migrations ./migrations
|
||||
COPY --from=frontend-build /app/frontend/dist ./public
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -1,59 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price_cents INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'available',
|
||||
reserved_until TIMESTAMPTZ,
|
||||
sold_at TIMESTAMPTZ,
|
||||
paypal_order_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
image_path TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
marketing_consent BOOLEAN NOT NULL DEFAULT false,
|
||||
marketing_consent_at TIMESTAMPTZ,
|
||||
marketing_consent_text TEXT,
|
||||
unsubscribe_token TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
item_id INTEGER REFERENCES items(id),
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
processor TEXT NOT NULL,
|
||||
processor_order_id TEXT,
|
||||
amount_cents INTEGER,
|
||||
status TEXT,
|
||||
raw_event JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
const { runner } = require('node-pg-migrate');
|
||||
const path = require('path');
|
||||
|
||||
const direction = process.argv[2] || 'up';
|
||||
|
||||
runner({
|
||||
databaseUrl: {
|
||||
host: process.env.PGHOST || 'localhost',
|
||||
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE
|
||||
},
|
||||
dir: path.resolve(__dirname, 'migrations'),
|
||||
direction,
|
||||
migrationsTable: 'pgmigrations',
|
||||
count: direction === 'down' ? 1 : Infinity,
|
||||
log: (msg) => console.log(msg)
|
||||
})
|
||||
.then((applied) => {
|
||||
console.log(`Migration complete — ${applied.length} migration(s) ${direction === 'down' ? 'reverted' : 'applied'}.`);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price_cents INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'available',
|
||||
reserved_until TIMESTAMPTZ,
|
||||
sold_at TIMESTAMPTZ,
|
||||
paypal_order_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
image_path TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
marketing_consent BOOLEAN NOT NULL DEFAULT false,
|
||||
marketing_consent_at TIMESTAMPTZ,
|
||||
marketing_consent_text TEXT,
|
||||
unsubscribe_token TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO admin_settings (key, value) VALUES ('cart_expiry_hours', '24') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS carts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL UNIQUE REFERENCES customers(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cart_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
last_reminder_sent_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shipping_addresses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
full_name TEXT NOT NULL,
|
||||
address_line1 TEXT NOT NULL,
|
||||
address_line2 TEXT,
|
||||
city TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
postal_code TEXT NOT NULL,
|
||||
country TEXT NOT NULL DEFAULT 'US',
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
usps_validated BOOLEAN NOT NULL DEFAULT false,
|
||||
usps_standardized JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkouts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
shipping_address_id INTEGER REFERENCES shipping_addresses(id) ON DELETE SET NULL,
|
||||
processor TEXT NOT NULL,
|
||||
processor_order_id TEXT,
|
||||
amount_cents INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
raw_event JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkout_items (
|
||||
checkout_id INTEGER NOT NULL REFERENCES checkouts(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
price_cents INTEGER NOT NULL,
|
||||
PRIMARY KEY (checkout_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
item_id INTEGER REFERENCES items(id),
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
checkout_id INTEGER REFERENCES checkouts(id) ON DELETE SET NULL,
|
||||
processor TEXT NOT NULL,
|
||||
processor_order_id TEXT,
|
||||
amount_cents INTEGER,
|
||||
status TEXT,
|
||||
raw_event JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
// Deliberately no destructive down migration for the baseline — reverting this
|
||||
// would drop every table and all data. If you ever need to roll back past this
|
||||
// point, do it manually and deliberately, not via `migrate:down`.
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`SELECT 1;`);
|
||||
};
|
||||
@@ -13,7 +13,10 @@
|
||||
"test:integration": "jest -c jest.integration.config.js --runInBand",
|
||||
"test:integration:json": "jest -c jest.integration.config.js --runInBand --json --outputFile=integration-results.json",
|
||||
"db:test:up": "docker compose -f docker-compose.test.yml up -d",
|
||||
"db:test:down": "docker compose -f docker-compose.test.yml down -v"
|
||||
"db:test:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"migrate:up": "node migrate.js up",
|
||||
"migrate:down": "node migrate.js down",
|
||||
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
@@ -21,7 +24,9 @@
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"nodemailer": "^6.9.14"
|
||||
"nodemailer": "^6.9.14",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pg-migrate": "^7.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4",
|
||||
@@ -33,6 +38,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Pool } from 'pg';
|
||||
import fs from 'fs';
|
||||
import { runner } from 'node-pg-migrate';
|
||||
import path from 'path';
|
||||
|
||||
export const testPool = new Pool({
|
||||
@@ -11,17 +11,29 @@ export const testPool = new Pool({
|
||||
});
|
||||
|
||||
export async function migrate(): Promise<void> {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'init.sql'), 'utf-8');
|
||||
await testPool.query(sql);
|
||||
await runner({
|
||||
databaseUrl: {
|
||||
host: process.env.TEST_PGHOST || 'localhost',
|
||||
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
||||
user: process.env.TEST_PGUSER || 'redefined_test',
|
||||
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
||||
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
||||
},
|
||||
dir: path.resolve(__dirname, '..', '..', '..', 'migrations'),
|
||||
direction: 'up',
|
||||
migrationsTable: 'pgmigrations',
|
||||
count: Infinity
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetDb(): Promise<void> {
|
||||
await testPool.query(`
|
||||
TRUNCATE TABLE orders, customer_tokens, customer_sessions, customers, item_images, items
|
||||
TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts,
|
||||
customer_tokens, customer_sessions, customers, item_images, items
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
await testPool.end();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user