feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Export all application tables from the local (source) database to a JSON
|
||||
* file, preserving column types for the matching import-data.mjs script.
|
||||
*
|
||||
* Usage (from apps/api, against the local DB in .env):
|
||||
* node scripts/export-data.mjs <output.json>
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
const TABLES = [
|
||||
'users',
|
||||
'countries',
|
||||
'categories',
|
||||
'tag_groups',
|
||||
'tags',
|
||||
'positions',
|
||||
'origin_goods',
|
||||
'origin_good_variants',
|
||||
'origin_good_details',
|
||||
'goods',
|
||||
'good_tags',
|
||||
'sync_logs',
|
||||
];
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Serialize values losslessly; import side uses information_schema to restore types.
|
||||
function serialize(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
|
||||
if (typeof value === 'object') return JSON.stringify(value); // jsonb
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const out = process.argv[2];
|
||||
if (!out) {
|
||||
console.error('Usage: node scripts/export-data.mjs <output.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dump = { exportedAt: new Date().toISOString(), tables: {} };
|
||||
for (const table of TABLES) {
|
||||
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
|
||||
dump.tables[table] = rows.map((row) => {
|
||||
const o = {};
|
||||
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
|
||||
return o;
|
||||
});
|
||||
console.log(`${table}: ${rows.length} rows`);
|
||||
}
|
||||
|
||||
writeFileSync(out, JSON.stringify(dump));
|
||||
console.log(`Wrote ${out}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Import a dump produced by export-data.mjs into the current database.
|
||||
* Tables are truncated first (order-independent via session_replication_role)
|
||||
* and columns are cast back to their real types using information_schema.
|
||||
*
|
||||
* Usage (inside the api container):
|
||||
* node scripts/import-data.mjs <dump.json>
|
||||
*/
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function toLiteral(value, udtName) {
|
||||
if (value === null) return Prisma.sql`NULL`;
|
||||
const target = Prisma.raw(`"${udtName}"`);
|
||||
switch (udtName) {
|
||||
case 'int2':
|
||||
case 'int4':
|
||||
case 'int8':
|
||||
return Prisma.sql`${BigInt(value)}::${target}`;
|
||||
case 'float4':
|
||||
case 'float8':
|
||||
case 'numeric':
|
||||
return Prisma.sql`${Number(value)}::${target}`;
|
||||
case 'bool':
|
||||
return Prisma.sql`${!!value}::${target}`;
|
||||
case 'timestamptz':
|
||||
case 'timestamp':
|
||||
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
|
||||
case 'date':
|
||||
return Prisma.sql`${String(value)}::${target}`;
|
||||
case 'jsonb':
|
||||
case 'json':
|
||||
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
|
||||
case 'bytea':
|
||||
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
|
||||
default:
|
||||
// text, varchar, enums and anything else: pass as text and cast
|
||||
return Prisma.sql`${String(value)}::${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('Usage: node scripts/import-data.mjs <dump.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
const dump = JSON.parse(readFileSync(file, 'utf8'));
|
||||
|
||||
// Suspend FK checks during bulk load (postgres superuser not required for
|
||||
// session_replication_role in the compose postgres where app user owns db).
|
||||
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
|
||||
|
||||
const summary = {};
|
||||
for (const [table, rows] of Object.entries(dump.tables)) {
|
||||
if (rows.length === 0) {
|
||||
summary[table] = 0;
|
||||
continue;
|
||||
}
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
|
||||
|
||||
const colTypes = {};
|
||||
const info = await prisma.$queryRawUnsafe(
|
||||
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
|
||||
);
|
||||
for (const c of info) colTypes[c.column_name] = c.udt_name;
|
||||
|
||||
const columns = Object.keys(rows[0]);
|
||||
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
|
||||
const CHUNK = 200;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const tuples = rows.slice(i, i + CHUNK).map(
|
||||
(r) =>
|
||||
Prisma.sql`(${Prisma.join(
|
||||
columns.map((c) => toLiteral(r[c], colTypes[c])),
|
||||
)})`,
|
||||
);
|
||||
await prisma.$executeRaw(
|
||||
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
|
||||
);
|
||||
}
|
||||
// Keep sequences ahead of imported serial ids
|
||||
// Keep serial sequences ahead of imported ids
|
||||
const idCol = columns.find(
|
||||
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
|
||||
);
|
||||
if (idCol) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
|
||||
);
|
||||
}
|
||||
summary[table] = rows.length;
|
||||
}
|
||||
|
||||
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
|
||||
console.log('Imported:', JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Set the admin credentials for the production deployment:
|
||||
* rename/disable any existing admin and create/update user `inkreach`.
|
||||
*
|
||||
* Usage (inside the api container): node scripts/set-admin.mjs
|
||||
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const username = process.env.NEW_ADMIN_USER;
|
||||
const password = process.env.NEW_ADMIN_PASSWORD;
|
||||
if (!username || !password) {
|
||||
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.upsert({
|
||||
where: { username },
|
||||
create: { username, passwordHash },
|
||||
update: { passwordHash, tokenVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Remove every other admin so only `inkreach` can sign in.
|
||||
const others = await prisma.user.deleteMany({
|
||||
where: { username: { not: username } },
|
||||
});
|
||||
|
||||
const total = await prisma.user.count();
|
||||
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user