Initial commit of the Mado Motor NestJS API.

This commit is contained in:
2026-09-03 23:41:32 +03:30
commit 9ffd26c318
90 changed files with 9949 additions and 0 deletions
+63
View File
@@ -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()
})
+396
View File
@@ -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()
})
+320
View File
@@ -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(/&amp;/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()
})
+53
View File
@@ -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);
});