Ship legacy migrate APIs, portfolio/blog old-id schema, and admin migrate/purge flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-29 16:10:41 +03:30
co-authored by Cursor
parent 7244b70e90
commit 4598add88c
32 changed files with 4234 additions and 265 deletions
+10
View File
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { LegacyMysqlService } from './legacy-mysql.service';
import { LegacySourceS3Service } from './legacy-source-s3.service';
@Global()
@Module({
providers: [LegacyMysqlService, LegacySourceS3Service],
exports: [LegacyMysqlService, LegacySourceS3Service],
})
export class LegacyMysqlModule {}
+71
View File
@@ -0,0 +1,71 @@
import {
Injectable,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import mysql, { Pool, PoolOptions, RowDataPacket } from 'mysql2/promise';
@Injectable()
export class LegacyMysqlService implements OnModuleDestroy {
private pool: Pool | null = null;
constructor(private readonly config: ConfigService) {}
async onModuleDestroy() {
if (this.pool) {
await this.pool.end();
this.pool = null;
}
}
isConfigured(): boolean {
return Boolean(
this.config.get<string>('OLD_MYSQL_HOST') &&
this.config.get<string>('OLD_MYSQL_USER') &&
this.config.get<string>('OLD_MYSQL_DATABASE'),
);
}
async query<T extends RowDataPacket[]>(
sql: string,
params: unknown[] = [],
): Promise<T> {
const pool = this.getPool();
try {
const [rows] = await pool.query<T>(sql, params);
return rows;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown MySQL error';
throw new ServiceUnavailableException(
`Old CMS MySQL query failed: ${message}. Ensure the SSH tunnel is up and OLD_MYSQL_* is set.`,
);
}
}
private getPool(): Pool {
if (this.pool) {
return this.pool;
}
if (!this.isConfigured()) {
throw new ServiceUnavailableException(
'Old CMS MySQL is not configured. Set OLD_MYSQL_HOST, OLD_MYSQL_USER, OLD_MYSQL_DATABASE (and password/port).',
);
}
const options: PoolOptions = {
host: this.config.getOrThrow<string>('OLD_MYSQL_HOST'),
port: Number(this.config.get<string>('OLD_MYSQL_PORT', '3307')),
user: this.config.getOrThrow<string>('OLD_MYSQL_USER'),
password: this.config.get<string>('OLD_MYSQL_PASSWORD', ''),
database: this.config.getOrThrow<string>('OLD_MYSQL_DATABASE'),
waitForConnections: true,
connectionLimit: 4,
namedPlaceholders: false,
};
this.pool = mysql.createPool(options);
return this.pool;
}
}
@@ -0,0 +1,207 @@
import {
Injectable,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
S3Client,
} from '@aws-sdk/client-s3';
import { createHash } from 'crypto';
@Injectable()
export class LegacySourceS3Service implements OnModuleDestroy {
private client: S3Client | null = null;
private bucket = '';
private publicUrlBase = '';
private readonly prefixCache = new Map<number, string>();
constructor(private readonly config: ConfigService) {}
async onModuleDestroy() {
this.client?.destroy();
this.client = null;
}
isConfigured(): boolean {
return Boolean(
this.config.get<string>('OLD_S3_ENDPOINT') &&
this.config.get<string>('OLD_S3_BUCKET') &&
this.config.get<string>('OLD_S3_ACCESS_KEY_ID') &&
this.config.get<string>('OLD_S3_SECRET_ACCESS_KEY'),
);
}
spatieObjectKey(prefix: string, mediaId: number, fileName: string): string {
const hash = createHash('md5').update(String(mediaId)).digest('hex');
return `${prefix}/${hash}/${fileName}`;
}
async resolveBusinessPrefix(
oldBusinessId: number,
slugHint: string | null,
probes: Array<{ id: number; fileName: string }>,
): Promise<string> {
const cached = this.prefixCache.get(oldBusinessId);
if (cached) return cached;
const candidates: string[] = [];
const seen = new Set<string>();
const add = (prefix: string) => {
if (!prefix || seen.has(prefix)) return;
seen.add(prefix);
candidates.push(prefix);
};
if (slugHint?.trim()) {
const slug = slugHint.trim();
add(`${slug}_${oldBusinessId}`);
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
}
const listed = await this.listTopLevelPrefixes();
for (const prefix of listed) {
if (prefix.endsWith(`_${oldBusinessId}`)) {
add(prefix);
}
}
if (!candidates.length) {
throw new ServiceUnavailableException(
`Could not resolve old S3 path prefix for business ${oldBusinessId}. Check OLD_S3_* and that files exist.`,
);
}
const sample = probes.slice(0, 12);
let bestPrefix = candidates[0];
let bestHits = -1;
for (const prefix of candidates) {
let hits = 0;
for (const probe of sample) {
const key = this.spatieObjectKey(prefix, probe.id, probe.fileName);
if (await this.objectExists(key)) {
hits += 1;
}
}
if (hits > bestHits) {
bestHits = hits;
bestPrefix = prefix;
}
if (bestHits === sample.length && sample.length > 0) {
break;
}
}
if (bestHits <= 0 && sample.length > 0) {
throw new ServiceUnavailableException(
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
);
}
this.prefixCache.set(oldBusinessId, bestPrefix);
return bestPrefix;
}
async getObjectBuffer(key: string): Promise<Buffer> {
const { client, bucket } = this.getClient();
try {
const result = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
);
if (!result.Body) {
throw new Error(`Empty body for ${key}`);
}
return Buffer.from(await result.Body.transformToByteArray());
} catch (err) {
// Public URL fallback (path-style bucket public reads)
if (this.publicUrlBase) {
const res = await fetch(`${this.publicUrlBase}/${key}`);
if (res.ok) {
return Buffer.from(await res.arrayBuffer());
}
}
const message = err instanceof Error ? err.message : 'Unknown S3 error';
throw new ServiceUnavailableException(
`Old CMS S3 get failed for ${key}: ${message}`,
);
}
}
private async objectExists(key: string): Promise<boolean> {
const { client, bucket } = this.getClient();
try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return true;
} catch {
if (this.publicUrlBase) {
try {
const res = await fetch(`${this.publicUrlBase}/${key}`, {
method: 'HEAD',
});
return res.ok;
} catch {
return false;
}
}
return false;
}
}
private async listTopLevelPrefixes(): Promise<string[]> {
const { client, bucket } = this.getClient();
const prefixes: string[] = [];
let token: string | undefined;
do {
const res = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Delimiter: '/',
ContinuationToken: token,
MaxKeys: 1000,
}),
);
for (const p of res.CommonPrefixes ?? []) {
const raw = p.Prefix?.replace(/\/$/, '');
if (raw) prefixes.push(raw);
}
token = res.IsTruncated ? res.NextContinuationToken : undefined;
} while (token);
return prefixes;
}
private getClient(): { client: S3Client; bucket: string } {
if (this.client) {
return { client: this.client, bucket: this.bucket };
}
if (!this.isConfigured()) {
throw new ServiceUnavailableException(
'Old CMS S3 is not configured. Set OLD_S3_ENDPOINT, OLD_S3_BUCKET, OLD_S3_ACCESS_KEY_ID, OLD_S3_SECRET_ACCESS_KEY.',
);
}
this.bucket = this.config.getOrThrow<string>('OLD_S3_BUCKET');
this.publicUrlBase = (
this.config.get<string>('OLD_S3_PUBLIC_URL') ?? ''
).replace(/\/$/, '');
this.client = new S3Client({
endpoint: this.config.getOrThrow<string>('OLD_S3_ENDPOINT'),
region: this.config.get<string>('OLD_S3_REGION', 'us-east-1'),
forcePathStyle:
this.config.get<string>('OLD_S3_FORCE_PATH_STYLE', 'true') === 'true',
credentials: {
accessKeyId: this.config.getOrThrow<string>('OLD_S3_ACCESS_KEY_ID'),
secretAccessKey: this.config.getOrThrow<string>(
'OLD_S3_SECRET_ACCESS_KEY',
),
},
});
return { client: this.client, bucket: this.bucket };
}
}