import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { PG } from '../db/db.module'; import { asJson, Sql } from '../db/db'; export interface MerchantRow { id: string; external_id: string; name: string; industry_id: string | null; region_id: string | null; description: string; profile: Record; site_url: string | null; last_generated_at: Date | null; } export interface MerchantWithTaxonomy extends MerchantRow { industry_name: string | null; industry_path: string | null; region_name: string | null; region_path: string | null; } export interface UpsertMerchantDto { externalId: string; name: string; industryId?: string | null; regionId?: string | null; description?: string; profile?: Record; siteUrl?: string | null; } @Injectable() export class MerchantsService { constructor(@Inject(PG) private readonly sql: Sql) {} /** o2o-site-AEO 가 사이트를 발행할 때 호출하는 진입점 */ async upsert(dto: UpsertMerchantDto): Promise { const rows = await this.sql` INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url) VALUES (${dto.externalId}, ${dto.name}, ${dto.industryId ?? null}, ${dto.regionId ?? null}, ${dto.description ?? ''}, ${this.sql.json(asJson(dto.profile ?? {}))}, ${dto.siteUrl ?? null}) ON CONFLICT (external_id) DO UPDATE SET name = EXCLUDED.name, industry_id = EXCLUDED.industry_id, region_id = EXCLUDED.region_id, description = EXCLUDED.description, profile = EXCLUDED.profile, site_url = EXCLUDED.site_url, updated_at = now() RETURNING *`; return rows[0]; } async findWithTaxonomy(idOrExternalId: string): Promise { const rows = await this.sql` SELECT m.*, i.name AS industry_name, i.path::text AS industry_path, r.name AS region_name, r.path::text AS region_path FROM merchant m LEFT JOIN industry i ON i.id = m.industry_id LEFT JOIN region r ON r.id = m.region_id WHERE m.external_id = ${idOrExternalId} OR (${isUuid(idOrExternalId)} AND m.id::text = ${idOrExternalId}) LIMIT 1`; if (!rows[0]) throw new NotFoundException(`merchant not found: ${idOrExternalId}`); return rows[0]; } async list(limit = 50) { return this.sql` SELECT * FROM merchant ORDER BY created_at DESC LIMIT ${limit}`; } /** 주기 리프레시 대상: 한 번도 생성 안 됐거나 N일 지난 업체 */ async findStale(intervalDays: number, limit: number) { return this.sql>` SELECT id, external_id FROM merchant WHERE last_generated_at IS NULL OR last_generated_at < now() - (${intervalDays} || ' days')::interval ORDER BY last_generated_at NULLS FIRST LIMIT ${limit}`; } async markGenerated(merchantId: string) { await this.sql`UPDATE merchant SET last_generated_at = now() WHERE id = ${merchantId}`; } } const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const isUuid = (v: string) => UUID_RE.test(v);