Initial commit of the Mado Motor NestJS API.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Create the first superAdmin.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register scripts/create-super-admin.ts \
|
||||
* --phone 09127004945 --password secret123 --first علیرضا --last حسنی
|
||||
*/
|
||||
import { PrismaClient, UserRole } from '@prisma/client'
|
||||
import * as bcrypt from 'bcrypt'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
function arg(name: string, fallback?: string): string {
|
||||
const idx = process.argv.indexOf(`--${name}`)
|
||||
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1]
|
||||
if (fallback !== undefined) return fallback
|
||||
throw new Error(`Missing --${name}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cellNumber = arg('phone')
|
||||
const password = arg('password')
|
||||
const firstName = arg('first', 'علیرضا')
|
||||
const lastName = arg('last', 'حسنی')
|
||||
|
||||
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||
throw new Error('phone must match 09xxxxxxxxx')
|
||||
}
|
||||
if (password.length < 4) {
|
||||
throw new Error('password min length 4')
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { cellNumber } })
|
||||
if (existing) {
|
||||
throw new Error(`User already exists: ${cellNumber}`)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
cellNumber,
|
||||
passwordHash,
|
||||
role: UserRole.superAdmin,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created superAdmin:', {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Import JSON exported from production MySQL (VM) into local Postgres.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register scripts/import-legacy-export.ts
|
||||
*
|
||||
* Optional:
|
||||
* --dir scripts/data
|
||||
* --replace wipe products/blogs/representations (and non-kept users) before import
|
||||
*/
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
PrismaClient,
|
||||
RepresentationStatus,
|
||||
UserRole,
|
||||
} from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
function arg(name: string, fallback?: string): string | undefined {
|
||||
const idx = process.argv.indexOf(`--${name}`)
|
||||
if (idx >= 0 && process.argv[idx + 1] && !process.argv[idx + 1].startsWith('--')) {
|
||||
return process.argv[idx + 1]
|
||||
}
|
||||
if (process.argv.includes(`--${name}`)) return 'true'
|
||||
return fallback
|
||||
}
|
||||
|
||||
function legacyId(prefix: string, id: string | number): string {
|
||||
return `legacy-${prefix}-${id}`
|
||||
}
|
||||
|
||||
function normalizePhone(raw: string | null | undefined): string {
|
||||
const digits = String(raw ?? '').replace(/\D/g, '')
|
||||
if (digits.length === 10 && digits.startsWith('9')) return `0${digits}`
|
||||
if (digits.length === 11 && digits.startsWith('09')) return digits
|
||||
return digits || '09000000000'
|
||||
}
|
||||
|
||||
function mapRole(role: string | null | undefined, cellNumber: string): UserRole {
|
||||
if (cellNumber === '09127004945') return UserRole.superAdmin
|
||||
switch (role) {
|
||||
case 'Admin':
|
||||
return UserRole.Admin
|
||||
case 'Agent':
|
||||
return UserRole.Agent
|
||||
default:
|
||||
return UserRole.User
|
||||
}
|
||||
}
|
||||
|
||||
function mapRequestStatus(status: number): RepresentationStatus {
|
||||
if (status === 2) return RepresentationStatus.active
|
||||
if (status === 1) return RepresentationStatus.rejected
|
||||
return RepresentationStatus.pending
|
||||
}
|
||||
|
||||
function toDate(value: string | null | undefined): Date | undefined {
|
||||
if (!value) return undefined
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
|
||||
function phpBcryptToNode(hash: string): string {
|
||||
// Node bcrypt accepts $2y$ in most versions; normalize to $2b$ just in case.
|
||||
if (hash.startsWith('$2y$')) return `$2b$${hash.slice(4)}`
|
||||
return hash
|
||||
}
|
||||
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
const raw = await fs.readFile(file, 'utf8')
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = path.resolve(
|
||||
process.cwd(),
|
||||
arg('dir', 'scripts/data') ?? 'scripts/data',
|
||||
)
|
||||
const replace = Boolean(arg('replace'))
|
||||
|
||||
type UserRow = {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
mobile: string
|
||||
password: string
|
||||
role: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type ProductRow = {
|
||||
id: string
|
||||
title_fa: string
|
||||
title_en: string
|
||||
price: string
|
||||
quantity: string
|
||||
category: string
|
||||
description: string
|
||||
image_url: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type BlogRow = {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
body: string
|
||||
author: string
|
||||
published: boolean
|
||||
image_url: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type AgentRow = {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
national_code: string
|
||||
mobile: string
|
||||
phone: string
|
||||
postal_code: string
|
||||
address: string
|
||||
description: string
|
||||
city: string
|
||||
province: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
type RequestRow = {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
national_code: string
|
||||
mobile: string
|
||||
phone: string
|
||||
address: string
|
||||
postal_code: string
|
||||
description: string
|
||||
status: number
|
||||
city: string
|
||||
province: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
const users = await readJson<UserRow[]>(path.join(dir, 'users.json'))
|
||||
const products = await readJson<ProductRow[]>(path.join(dir, 'products.json'))
|
||||
const blogs = await readJson<BlogRow[]>(path.join(dir, 'blogs.json'))
|
||||
const agents = await readJson<AgentRow[]>(path.join(dir, 'agents.json'))
|
||||
const requests = await readJson<RequestRow[]>(
|
||||
path.join(dir, 'agent_requests.json'),
|
||||
)
|
||||
|
||||
if (replace) {
|
||||
console.log('Clearing existing catalog / representation / non-superAdmin users…')
|
||||
await prisma.orderItem.deleteMany({})
|
||||
await prisma.order.deleteMany({})
|
||||
await prisma.review.deleteMany({})
|
||||
await prisma.callRequest.deleteMany({})
|
||||
await prisma.representation.deleteMany({})
|
||||
await prisma.blogPost.deleteMany({})
|
||||
await prisma.product.deleteMany({})
|
||||
await prisma.refreshToken.deleteMany({})
|
||||
await prisma.smsOtp.deleteMany({})
|
||||
await prisma.user.deleteMany({
|
||||
where: { role: { not: UserRole.superAdmin } },
|
||||
})
|
||||
}
|
||||
|
||||
let usersUpserted = 0
|
||||
for (const u of users) {
|
||||
const cellNumber = normalizePhone(u.mobile)
|
||||
const id = legacyId('user', u.id)
|
||||
const role = mapRole(u.role, cellNumber)
|
||||
const passwordHash = phpBcryptToNode(u.password)
|
||||
const data = {
|
||||
firstName: (u.first_name || '—').slice(0, 190),
|
||||
lastName: (u.last_name || '—').slice(0, 190),
|
||||
cellNumber,
|
||||
passwordHash,
|
||||
role,
|
||||
disabled: false,
|
||||
createdAt: toDate(u.created_at),
|
||||
updatedAt: toDate(u.updated_at),
|
||||
}
|
||||
|
||||
const byPhone = await prisma.user.findUnique({ where: { cellNumber } })
|
||||
if (byPhone) {
|
||||
await prisma.user.update({
|
||||
where: { id: byPhone.id },
|
||||
data: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
passwordHash: data.passwordHash,
|
||||
// keep local superAdmin elevation
|
||||
role:
|
||||
byPhone.role === UserRole.superAdmin || role === UserRole.superAdmin
|
||||
? UserRole.superAdmin
|
||||
: role,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.user.upsert({
|
||||
where: { id },
|
||||
create: { id, ...data },
|
||||
update: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
cellNumber: data.cellNumber,
|
||||
passwordHash: data.passwordHash,
|
||||
role: data.role,
|
||||
},
|
||||
})
|
||||
}
|
||||
usersUpserted += 1
|
||||
}
|
||||
|
||||
let productsUpserted = 0
|
||||
for (const p of products) {
|
||||
const id = legacyId('product', p.id)
|
||||
const price = Number.parseInt(String(p.price || '0'), 10) || 0
|
||||
const stock = Number.parseInt(String(p.quantity || '0'), 10) || 0
|
||||
await prisma.product.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
nameFa: p.title_fa || 'بدون عنوان',
|
||||
nameEn: p.title_en || '',
|
||||
price,
|
||||
stock,
|
||||
category: p.category || '',
|
||||
imageUrl: p.image_url || null,
|
||||
createdAt: toDate(p.created_at),
|
||||
updatedAt: toDate(p.updated_at),
|
||||
},
|
||||
update: {
|
||||
nameFa: p.title_fa || 'بدون عنوان',
|
||||
nameEn: p.title_en || '',
|
||||
price,
|
||||
stock,
|
||||
category: p.category || '',
|
||||
imageUrl: p.image_url || null,
|
||||
},
|
||||
})
|
||||
productsUpserted += 1
|
||||
}
|
||||
|
||||
let blogsUpserted = 0
|
||||
for (const b of blogs) {
|
||||
const id = legacyId('blog', b.id)
|
||||
await prisma.blogPost.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
title: b.title || 'بدون عنوان',
|
||||
summary: b.summary || '',
|
||||
body: b.body || '',
|
||||
author: (b.author || '').trim(),
|
||||
imageUrl: b.image_url || null,
|
||||
published: Boolean(b.published),
|
||||
createdAt: toDate(b.created_at),
|
||||
updatedAt: toDate(b.updated_at),
|
||||
},
|
||||
update: {
|
||||
title: b.title || 'بدون عنوان',
|
||||
summary: b.summary || '',
|
||||
body: b.body || '',
|
||||
author: (b.author || '').trim(),
|
||||
imageUrl: b.image_url || null,
|
||||
published: Boolean(b.published),
|
||||
},
|
||||
})
|
||||
blogsUpserted += 1
|
||||
}
|
||||
|
||||
let repsUpserted = 0
|
||||
for (const a of agents) {
|
||||
const id = legacyId('agent', a.id)
|
||||
const mobile = normalizePhone(a.mobile)
|
||||
const owner = a.owner || a.name || ''
|
||||
const nameParts = owner.trim().split(/\s+/)
|
||||
await prisma.representation.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
name: a.name || owner || `نمایندگی ${a.id}`,
|
||||
owner,
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(1).join(' '),
|
||||
nationalCode: a.national_code || '',
|
||||
mobile,
|
||||
province: a.province || '',
|
||||
city: a.city || '',
|
||||
landline: a.phone || '',
|
||||
address: a.address || '',
|
||||
postalCode: a.postal_code || '',
|
||||
description: a.description || '',
|
||||
status: RepresentationStatus.active,
|
||||
createdAt: toDate(a.created_at),
|
||||
updatedAt: toDate(a.updated_at),
|
||||
},
|
||||
update: {
|
||||
name: a.name || owner || `نمایندگی ${a.id}`,
|
||||
owner,
|
||||
nationalCode: a.national_code || '',
|
||||
mobile,
|
||||
province: a.province || '',
|
||||
city: a.city || '',
|
||||
landline: a.phone || '',
|
||||
address: a.address || '',
|
||||
postalCode: a.postal_code || '',
|
||||
description: a.description || '',
|
||||
status: RepresentationStatus.active,
|
||||
},
|
||||
})
|
||||
repsUpserted += 1
|
||||
}
|
||||
|
||||
let requestsUpserted = 0
|
||||
for (const r of requests) {
|
||||
const id = legacyId('agent-request', r.id)
|
||||
const mobile = normalizePhone(r.mobile)
|
||||
const status = mapRequestStatus(Number(r.status) || 0)
|
||||
const name = `${r.first_name || ''} ${r.last_name || ''}`.trim()
|
||||
await prisma.representation.upsert({
|
||||
where: { id },
|
||||
create: {
|
||||
id,
|
||||
name: name || `درخواست ${r.id}`,
|
||||
owner: name,
|
||||
firstName: r.first_name || '',
|
||||
lastName: r.last_name || '',
|
||||
nationalCode: r.national_code || '',
|
||||
mobile,
|
||||
province: r.province || '',
|
||||
city: r.city || '',
|
||||
landline: r.phone || '',
|
||||
address: r.address || '',
|
||||
postalCode: r.postal_code || '',
|
||||
description: r.description || '',
|
||||
status,
|
||||
createdAt: toDate(r.created_at),
|
||||
updatedAt: toDate(r.updated_at),
|
||||
},
|
||||
update: {
|
||||
name: name || `درخواست ${r.id}`,
|
||||
owner: name,
|
||||
firstName: r.first_name || '',
|
||||
lastName: r.last_name || '',
|
||||
nationalCode: r.national_code || '',
|
||||
mobile,
|
||||
province: r.province || '',
|
||||
city: r.city || '',
|
||||
landline: r.phone || '',
|
||||
address: r.address || '',
|
||||
postalCode: r.postal_code || '',
|
||||
description: r.description || '',
|
||||
status,
|
||||
},
|
||||
})
|
||||
requestsUpserted += 1
|
||||
}
|
||||
|
||||
const counts = {
|
||||
users: await prisma.user.count(),
|
||||
products: await prisma.product.count(),
|
||||
blogs: await prisma.blogPost.count(),
|
||||
representations: await prisma.representation.count(),
|
||||
activeReps: await prisma.representation.count({
|
||||
where: { status: RepresentationStatus.active },
|
||||
}),
|
||||
pendingReps: await prisma.representation.count({
|
||||
where: { status: RepresentationStatus.pending },
|
||||
}),
|
||||
}
|
||||
|
||||
console.log('Imported:', {
|
||||
usersUpserted,
|
||||
productsUpserted,
|
||||
blogsUpserted,
|
||||
agentsAsActive: repsUpserted,
|
||||
agentRequests: requestsUpserted,
|
||||
})
|
||||
console.log('DB totals:', counts)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Download images from legacy Parmin storage URLs and re-upload to our Parspack S3.
|
||||
* Updates Product/BlogPost imageUrl + imageKey, and rewrites URLs inside blog bodies.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node -r tsconfig-paths/register scripts/migrate-images-to-s3.ts
|
||||
*/
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { extname } from 'path'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import {
|
||||
blogMediaKey,
|
||||
productMainKey,
|
||||
} from '../src/storage/storage-keys'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const LEGACY_HOST_RE =
|
||||
/madomotor\.sas\.amin\.parminstorage\.ir|sas\.amin\.parminstorage\.ir/i
|
||||
|
||||
const EXT_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name]
|
||||
if (!v) throw new Error(`Missing env ${name}`)
|
||||
return v
|
||||
}
|
||||
|
||||
function publicUrlBase(): string {
|
||||
return requireEnv('S3_PUBLIC_URL').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function isAlreadyOurs(url: string | null | undefined): boolean {
|
||||
if (!url) return false
|
||||
return url.startsWith(publicUrlBase())
|
||||
}
|
||||
|
||||
function isLegacyUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return LEGACY_HOST_RE.test(u.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function guessExt(url: string, contentType: string | null): string {
|
||||
const pathPart = decodeURIComponent(new URL(url).pathname)
|
||||
const fromUrl = extname(pathPart).toLowerCase()
|
||||
if (fromUrl && fromUrl.length <= 5) return fromUrl
|
||||
if (contentType?.includes('png')) return '.png'
|
||||
if (contentType?.includes('webp')) return '.webp'
|
||||
if (contentType?.includes('gif')) return '.gif'
|
||||
return '.jpg'
|
||||
}
|
||||
|
||||
function mimeFor(ext: string, contentType: string | null): string {
|
||||
if (contentType && contentType.startsWith('image/')) {
|
||||
return contentType.split(';')[0].trim()
|
||||
}
|
||||
return EXT_MIME[ext] ?? 'image/jpeg'
|
||||
}
|
||||
|
||||
function extractUrls(text: string): string[] {
|
||||
const found = new Set<string>()
|
||||
const patterns = [
|
||||
/https?:\/\/[^\s"'<>]+/g,
|
||||
/src=["'](https?:\/\/[^"']+)["']/gi,
|
||||
]
|
||||
for (const re of patterns) {
|
||||
for (const match of text.matchAll(re)) {
|
||||
const url = (match[1] ?? match[0]).replace(/&/g, '&')
|
||||
found.add(url)
|
||||
}
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
type Kind = 'product' | 'blog' | 'blog-body'
|
||||
|
||||
function encodeFetchUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
// Encode each path segment so Persian filenames download reliably
|
||||
u.pathname = u.pathname
|
||||
.split('/')
|
||||
.map((seg) => {
|
||||
if (!seg) return seg
|
||||
try {
|
||||
return encodeURIComponent(decodeURIComponent(seg))
|
||||
} catch {
|
||||
return encodeURIComponent(seg)
|
||||
}
|
||||
})
|
||||
.join('/')
|
||||
return u.toString()
|
||||
} catch {
|
||||
return encodeURI(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function download(url: string): Promise<{
|
||||
buffer: Buffer
|
||||
contentType: string | null
|
||||
}> {
|
||||
const candidates = [url, encodeFetchUrl(url)]
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const res = await fetch(candidate, {
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent': 'mado-motor-image-migrator/1.0',
|
||||
Accept: 'image/*,*/*',
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`HTTP ${res.status} for ${candidate}`)
|
||||
continue
|
||||
}
|
||||
const contentType = res.headers.get('content-type')
|
||||
const buffer = Buffer.from(await res.arrayBuffer())
|
||||
if (buffer.length < 32) {
|
||||
lastError = new Error(`Tiny response (${buffer.length}b) for ${candidate}`)
|
||||
continue
|
||||
}
|
||||
return { buffer, contentType }
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Failed to download ${url}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const endpoint = requireEnv('S3_ENDPOINT')
|
||||
const bucket = requireEnv('S3_BUCKET')
|
||||
const base = publicUrlBase()
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE || 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: requireEnv('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
})
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
select: { id: true, imageUrl: true, imageKey: true },
|
||||
})
|
||||
const blogs = await prisma.blogPost.findMany({
|
||||
select: { id: true, imageUrl: true, imageKey: true, body: true },
|
||||
})
|
||||
|
||||
type Job = { url: string; kind: Kind; entityId?: string }
|
||||
const jobs: Job[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const enqueue = (url: string, kind: Kind, entityId?: string) => {
|
||||
const clean = url.replace(/[),.;]+$/, '')
|
||||
if (!isLegacyUrl(clean) || isAlreadyOurs(clean)) return
|
||||
if (seen.has(clean)) return
|
||||
seen.add(clean)
|
||||
jobs.push({ url: clean, kind, entityId })
|
||||
}
|
||||
|
||||
for (const p of products) {
|
||||
if (p.imageUrl) enqueue(p.imageUrl, 'product', p.id)
|
||||
}
|
||||
for (const b of blogs) {
|
||||
if (b.imageUrl) enqueue(b.imageUrl, 'blog', b.id)
|
||||
for (const u of extractUrls(b.body || '')) {
|
||||
enqueue(u, 'blog-body')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${jobs.length} unique legacy URLs to migrate`)
|
||||
|
||||
const urlMap = new Map<string, { url: string; key: string }>()
|
||||
let ok = 0
|
||||
let failed = 0
|
||||
|
||||
for (const [i, job] of jobs.entries()) {
|
||||
const label = `[${i + 1}/${jobs.length}]`
|
||||
try {
|
||||
const { buffer, contentType } = await download(job.url)
|
||||
const ext = guessExt(job.url, contentType)
|
||||
const fileName = `${randomUUID()}${ext}`
|
||||
const key =
|
||||
job.kind === 'product'
|
||||
? productMainKey(fileName)
|
||||
: blogMediaKey(
|
||||
job.kind === 'blog-body' ? `body/${fileName}` : fileName,
|
||||
)
|
||||
const contentTypeFinal = mimeFor(ext, contentType)
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentTypeFinal,
|
||||
}),
|
||||
)
|
||||
|
||||
const publicUrl = `${base}/${key}`
|
||||
urlMap.set(job.url, { url: publicUrl, key })
|
||||
|
||||
// also map common encoded/decoded variants
|
||||
try {
|
||||
urlMap.set(decodeURI(job.url), { url: publicUrl, key })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
urlMap.set(encodeURI(decodeURIComponent(job.url)), {
|
||||
url: publicUrl,
|
||||
key,
|
||||
})
|
||||
|
||||
ok += 1
|
||||
const hash = createHash('sha1').update(buffer).digest('hex').slice(0, 8)
|
||||
console.log(
|
||||
`${label} OK ${job.kind} ${buffer.length}b ${hash} → ${key}`,
|
||||
)
|
||||
} catch (err) {
|
||||
failed += 1
|
||||
console.error(
|
||||
`${label} FAIL ${job.url}`,
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update products
|
||||
let productsUpdated = 0
|
||||
for (const p of products) {
|
||||
if (!p.imageUrl) continue
|
||||
const mapped = urlMap.get(p.imageUrl) || urlMap.get(p.imageUrl.replace(/[),.;]+$/, ''))
|
||||
if (!mapped) continue
|
||||
await prisma.product.update({
|
||||
where: { id: p.id },
|
||||
data: { imageUrl: mapped.url, imageKey: mapped.key },
|
||||
})
|
||||
productsUpdated += 1
|
||||
}
|
||||
|
||||
// Update blogs (cover + body rewrite)
|
||||
let blogsUpdated = 0
|
||||
for (const b of blogs) {
|
||||
let body = b.body || ''
|
||||
let changed = false
|
||||
|
||||
for (const [oldUrl, mapped] of urlMap) {
|
||||
if (body.includes(oldUrl)) {
|
||||
body = body.split(oldUrl).join(mapped.url)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// also rewrite any remaining legacy host URLs that we mapped by pathname
|
||||
const stillLegacy = extractUrls(body).filter(isLegacyUrl)
|
||||
for (const u of stillLegacy) {
|
||||
const clean = u.replace(/[),.;]+$/, '')
|
||||
const mapped = urlMap.get(clean)
|
||||
if (mapped && body.includes(u)) {
|
||||
body = body.split(u).join(mapped.url)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
const coverMapped = b.imageUrl
|
||||
? urlMap.get(b.imageUrl) ||
|
||||
urlMap.get(b.imageUrl.replace(/[),.;]+$/, ''))
|
||||
: undefined
|
||||
|
||||
if (!changed && !coverMapped) continue
|
||||
|
||||
await prisma.blogPost.update({
|
||||
where: { id: b.id },
|
||||
data: {
|
||||
...(coverMapped
|
||||
? { imageUrl: coverMapped.url, imageKey: coverMapped.key }
|
||||
: {}),
|
||||
...(changed ? { body } : {}),
|
||||
},
|
||||
})
|
||||
blogsUpdated += 1
|
||||
}
|
||||
|
||||
console.log('Done:', {
|
||||
migrated: ok,
|
||||
failed,
|
||||
productsUpdated,
|
||||
blogsUpdated,
|
||||
publicBase: base,
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Send an SMS via Meshkee (backend-only; never call from frontend).
|
||||
*
|
||||
* Usage:
|
||||
* npm run send-sms -- --to 09127004945 --message "متن پیام"
|
||||
*/
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
loadEnv({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
function arg(name: string, fallback?: string): string {
|
||||
const idx = process.argv.indexOf(`--${name}`);
|
||||
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`Missing --${name}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const to = arg('to');
|
||||
const message = arg('message');
|
||||
const apiUrl =
|
||||
process.env.MESHKEE_SMS_URL ??
|
||||
'https://api.meshkee.com/api/v1/public/sms/send';
|
||||
const apiKey = process.env.MESHKEE_SMS_API_KEY;
|
||||
const domain = process.env.MESHKEE_SMS_DOMAIN ?? 'madomotor.ir';
|
||||
|
||||
if (!apiKey) throw new Error('MESHKEE_SMS_API_KEY is not set in .env');
|
||||
if (!/^09\d{9}$/.test(to)) throw new Error('to must match 09xxxxxxxxx');
|
||||
if (!message.trim()) throw new Error('message is empty');
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ domain, to, message }),
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.success) {
|
||||
console.error('SMS send failed', { status: response.status, body });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('SMS sent', { to, serverId: body.serverId });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user