api: fix migration runner, serve uploads correctly, add product photos
Three real bugs found and fixed while wiring up product images end-to-end: 1. src/prisma/migrate.ts only discovered flat prisma/migrations/<name>.sql files, never Prisma's own folder-per-migration layout (<timestamp>_<name>/migration.sql). Every migration created the normal way (via 'prisma migrate dev') was silently skipped; only a hand-added flat duplicate (prisma/migrations/0001_init.sql, now removed) ever ran. This meant schema.prisma had drifted well ahead of what any real deploy database actually had (missing columns on City, Customer, Branch, Product, several new tables). Fixed to discover both layouts; the drift is closed with a new 20260914165255_sync_schema_drift migration. 2. Neither nginx.conf (production) nor vite.config.ts (dev) proxied /uploads/ to the API — only /api/. UploadedFile/ProductImage URLs (/uploads/<file>, served by main.ts's useStaticAssets OUTSIDE the /api prefix) 404'd through both. Both now proxy /uploads/ the same way /api/ already was. 3. docker-compose.yml's api service had no volume for storage/uploads, so every uploaded file — and by extension every ProductImage row pointing at one — was lost on the next container recreate/redeploy. Added a named volume. Also: main.ts's static file serving now sets Content-Type: image/avif explicitly (serve-static's bundled mime table predates .avif and served it as application/octet-stream; browsers rendered it anyway by sniffing, but a stricter client would not have). Product photos: copied 15 real jewelry photos into the uploads volume, registered them as UploadedFile rows, and assigned each of the 5 existing Product rows a randomly picked one (duplicates allowed, per request), replacing their /img/no-items.png placeholder ProductImage row. Verified end-to-end in a real browser against the real containerized API+DB: logged in, opened the admin product list, confirmed all 5 rows render their photo (naturalWidth/Height populated, network 200s, no console errors). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a2ab9ba192
commit
2b6795d007
+13
-1
@@ -2,11 +2,19 @@ import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'node:path';
|
||||
import { extname, join } from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { prepareDatabase } from './prisma/bootstrap';
|
||||
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
||||
|
||||
// serve-static's bundled `mime` version predates .avif, so it falls back to
|
||||
// application/octet-stream — browsers still render the image (they sniff
|
||||
// content), but a strict client or an <img> under some CSPs would not.
|
||||
const EXTRA_MIME_TYPES: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
};
|
||||
|
||||
async function bootstrap() {
|
||||
await prepareDatabase();
|
||||
|
||||
@@ -21,6 +29,10 @@ async function bootstrap() {
|
||||
prefix: '/uploads/',
|
||||
index: false,
|
||||
dotfiles: 'deny',
|
||||
setHeaders: (res: Response, path: string) => {
|
||||
const mime = EXTRA_MIME_TYPES[extname(path).toLowerCase()];
|
||||
if (mime) res.setHeader('Content-Type', mime);
|
||||
},
|
||||
});
|
||||
// Bind all interfaces explicitly so the container is reachable from nginx.
|
||||
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@@ -105,11 +105,48 @@ export function splitSqlStatements(sql: string): string[] {
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug fixed here (was silently a no-op): this used to only look for flat
|
||||
* `<name>.sql` files directly under prisma/migrations. Prisma's own CLI
|
||||
* (`prisma migrate dev`) creates a FOLDER per migration instead
|
||||
* (`<timestamp>_<name>/migration.sql`), which `readdirSync(...).filter(name
|
||||
* => name.endsWith('.sql'))` never matches — a directory name doesn't end in
|
||||
* ".sql". Every migration created the normal way was silently skipped; only
|
||||
* a hand-added flat duplicate (prisma/migrations/0001_init.sql, now removed)
|
||||
* ever ran. Both layouts are now discovered, sorted together by name so
|
||||
* later migrations still apply after earlier ones regardless of shape.
|
||||
*/
|
||||
type MigrationFile = { name: string; path: string };
|
||||
|
||||
function discoverMigrationFiles(dir: string): MigrationFile[] {
|
||||
const entries = readdirSync(dir);
|
||||
const files: MigrationFile[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
|
||||
if (stat.isFile() && entry.endsWith('.sql')) {
|
||||
files.push({ name: entry, path: full });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const sqlPath = join(full, 'migration.sql');
|
||||
if (existsSync(sqlPath)) {
|
||||
// Tracked by the folder name (e.g. "20260910113720_first"), matching
|
||||
// how Prisma's own _prisma_migrations table identifies a migration.
|
||||
files.push({ name: entry, path: sqlPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function runMigrations(prisma: PrismaClient): Promise<MigrationResult> {
|
||||
const dir = resolveMigrationsDir();
|
||||
const files = readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.sql'))
|
||||
.sort();
|
||||
const files = discoverMigrationFiles(dir);
|
||||
|
||||
const trackingExisted = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(
|
||||
`SELECT EXISTS (
|
||||
@@ -134,9 +171,9 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
|
||||
|
||||
const applied: string[] = [];
|
||||
for (const file of files) {
|
||||
if (appliedNames.has(file)) continue;
|
||||
if (appliedNames.has(file.name)) continue;
|
||||
|
||||
const statements = splitSqlStatements(readFileSync(join(dir, file), 'utf8'));
|
||||
const statements = splitSqlStatements(readFileSync(file.path, 'utf8'));
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
for (const statement of statements) {
|
||||
@@ -144,14 +181,14 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
|
||||
}
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`,
|
||||
file,
|
||||
file.name,
|
||||
);
|
||||
},
|
||||
// The baseline migration is hundreds of DDL statements; the 5s default is
|
||||
// not enough, especially against a remote database.
|
||||
{ maxWait: 60_000, timeout: 600_000 },
|
||||
);
|
||||
applied.push(file);
|
||||
applied.push(file.name);
|
||||
}
|
||||
|
||||
return { freshDatabase, applied };
|
||||
|
||||
Reference in New Issue
Block a user