diff --git a/database/migrations/096_invoice_payment_methods.sql b/database/migrations/096_invoice_payment_methods.sql new file mode 100644 index 0000000..55ce49a --- /dev/null +++ b/database/migrations/096_invoice_payment_methods.sql @@ -0,0 +1,51 @@ +-- Meshkee CMS — invoice / template payment methods (نحوه ی پرداخت) + +-- --------------------------------------------------------------------------- +-- template payment methods (duplicatable free-text) +-- --------------------------------------------------------------------------- +CREATE TABLE invoice_template_payment_methods ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + template_id BIGINT NOT NULL, + text TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT invoice_template_payment_methods_template_id_fkey + FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE, + CONSTRAINT invoice_template_payment_methods_text_nonempty + CHECK (char_length(trim(text)) > 0) +); + +CREATE INDEX idx_invoice_template_payment_methods_template_id + ON invoice_template_payment_methods (template_id, sort_order); + +CREATE TRIGGER invoice_template_payment_methods_set_updated_at + BEFORE UPDATE ON invoice_template_payment_methods + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- invoice payment methods (duplicatable free-text) +-- --------------------------------------------------------------------------- +CREATE TABLE invoice_payment_methods ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + invoice_id BIGINT NOT NULL, + text TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT invoice_payment_methods_invoice_id_fkey + FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE, + CONSTRAINT invoice_payment_methods_text_nonempty + CHECK (char_length(trim(text)) > 0) +); + +CREATE INDEX idx_invoice_payment_methods_invoice_id + ON invoice_payment_methods (invoice_id, sort_order); + +CREATE TRIGGER invoice_payment_methods_set_updated_at + BEFORE UPDATE ON invoice_payment_methods + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 604df24..fd42603 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1380,11 +1380,12 @@ model InvoiceTemplate { isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - accounts InvoiceTemplateAccount[] - items InvoiceTemplateItem[] - keyPoints InvoiceTemplateKeyPoint[] - business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) - invoices Invoice[] + accounts InvoiceTemplateAccount[] + items InvoiceTemplateItem[] + keyPoints InvoiceTemplateKeyPoint[] + paymentMethods InvoiceTemplatePaymentMethod[] + business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + invoices Invoice[] @@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope") @@map("invoice_templates") @@ -1423,6 +1424,19 @@ model InvoiceTemplateKeyPoint { @@map("invoice_template_key_points") } +model InvoiceTemplatePaymentMethod { + id BigInt @id @default(autoincrement()) + templateId BigInt @map("template_id") + text String + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([templateId, sortOrder], map: "idx_invoice_template_payment_methods_template_id") + @@map("invoice_template_payment_methods") +} + model InvoiceTemplateAccount { id BigInt @id @default(autoincrement()) templateId BigInt @map("template_id") @@ -1458,6 +1472,7 @@ model Invoice { accounts InvoiceAccount[] items InvoiceItem[] keyPoints InvoiceKeyPoint[] + paymentMethods InvoicePaymentMethod[] business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction) issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction) @@ -1504,6 +1519,19 @@ model InvoiceKeyPoint { @@map("invoice_key_points") } +model InvoicePaymentMethod { + id BigInt @id @default(autoincrement()) + invoiceId BigInt @map("invoice_id") + text String + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([invoiceId, sortOrder], map: "idx_invoice_payment_methods_invoice_id") + @@map("invoice_payment_methods") +} + model InvoiceAccount { id BigInt @id @default(autoincrement()) invoiceId BigInt @map("invoice_id") diff --git a/src/business-admin/legacy-migrate.service.ts b/src/business-admin/legacy-migrate.service.ts index b4888c4..abb99d9 100644 --- a/src/business-admin/legacy-migrate.service.ts +++ b/src/business-admin/legacy-migrate.service.ts @@ -1379,7 +1379,7 @@ export class LegacyMigrateService { .filter((m) => Number(m.size) > 0) .slice(0, 12) .map((m) => ({ id: m.id, fileName: m.file_name })); - const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( + const storagePrefix = await this.tryResolveStoragePrefix( oldBizId, oldSlug, probeMedia, @@ -1417,7 +1417,7 @@ export class LegacyMigrateService { for (const media of articleMedia) { const size = Number(media.size) || 0; - if (size <= 0) { + if (size <= 0 || !storagePrefix) { imagesFailed += 1; continue; } @@ -1611,7 +1611,7 @@ export class LegacyMigrateService { .filter((m) => Number(m.size) > 0) .slice(0, 12) .map((m) => ({ id: m.id, fileName: m.file_name })); - const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( + const storagePrefix = await this.tryResolveStoragePrefix( oldBizId, oldSlug, probeMedia, @@ -1648,7 +1648,7 @@ export class LegacyMigrateService { for (const media of itemMedia) { const size = Number(media.size) || 0; - if (size <= 0) { + if (size <= 0 || !storagePrefix) { imagesFailed += 1; continue; } @@ -1978,6 +1978,27 @@ export class LegacyMigrateService { }; } + private async tryResolveStoragePrefix( + oldBizId: number, + oldSlug: string | null, + probeMedia: Array<{ id: number; fileName: string }>, + ): Promise { + if (!probeMedia.length) return null; + try { + return await this.legacySourceS3.resolveBusinessPrefix( + oldBizId, + oldSlug, + probeMedia, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `Legacy migrate: skipping image copy for business ${oldBizId}: ${message}`, + ); + return null; + } + } + private async copyLegacyMedia(input: { businessId: bigint; oldMedia: OldMediaRow; diff --git a/src/categories/category-technical-form-ai.service.ts b/src/categories/category-technical-form-ai.service.ts index 014b170..5f78b8f 100644 --- a/src/categories/category-technical-form-ai.service.ts +++ b/src/categories/category-technical-form-ai.service.ts @@ -126,10 +126,12 @@ export class CategoryTechnicalFormAiService { قوانین: - بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگی‌ها» را اضافه می‌کند). -- از ترکیب text، textarea، select، multi_select و multi_value استفاده کن. -- فیلدهای select و multi_select باید ۳ تا ۸ گزینه واقعی داشته باشند. +- ترجیحاً از type=text استفاده کن تا فروشنده بتواند هر مقدار دلخواهی را آزادانه وارد کند. +- از select و multi_select حتی‌المقدور استفاده نکن. فقط وقتی مجموعه گزینه‌ها واقعاً ثابت، کوتاه و استاندارد است (مثلاً بله/خیر یا حداکثر چند گزینه قطعی) مجازند؛ برای ظرفیت، توان، ابعاد، مدل، جنس، رنگ آزاد و مشابه آن‌ها همیشه text بگذار. +- اگر بین text و select مردد هستی، حتماً text انتخاب کن. +- فیلدهای select و multi_select (در صورت استفاده) باید ۳ تا ۸ گزینه واقعی داشته باشند. - multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی. -- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل). +- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل، ظرفیت، توان). - textarea برای مشخصات طولانی‌تر (ویژگی‌ها، دستورالعمل نگهداری). - اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را به‌صورت text اضافه کن (واحد متریک رایج ایران را در برچسب بیاور، مثلاً سانتی‌متر / کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن. - مشخصات مهم خریدار را الزامی علامت بزن. diff --git a/src/invoices/dto/invoice.dto.ts b/src/invoices/dto/invoice.dto.ts index d7fad3f..b2e7d50 100644 --- a/src/invoices/dto/invoice.dto.ts +++ b/src/invoices/dto/invoice.dto.ts @@ -57,6 +57,12 @@ export class InvoiceKeyPointInputDto { text!: string; } +export class InvoicePaymentMethodInputDto { + @IsString() + @MinLength(1) + text!: string; +} + export class InvoiceAccountInputDto { @IsString() @MinLength(1) @@ -114,6 +120,12 @@ export class CreateInvoiceDto { @Type(() => InvoiceKeyPointInputDto) keyPoints?: InvoiceKeyPointInputDto[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoicePaymentMethodInputDto) + paymentMethods?: InvoicePaymentMethodInputDto[]; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -134,7 +146,7 @@ export class UpdateInvoiceStatusDto { notes?: string; } -/** Full content replace for an existing invoice (items / key points / accounts). */ +/** Full content replace for an existing invoice (items / key points / payment methods / accounts). */ export class UpdateInvoiceDto { @IsOptional() @IsString() @@ -169,6 +181,12 @@ export class UpdateInvoiceDto { @Type(() => InvoiceKeyPointInputDto) keyPoints?: InvoiceKeyPointInputDto[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoicePaymentMethodInputDto) + paymentMethods?: InvoicePaymentMethodInputDto[]; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -337,6 +355,12 @@ export class CreateInvoiceTemplateDto { @Type(() => InvoiceKeyPointInputDto) keyPoints?: InvoiceKeyPointInputDto[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoicePaymentMethodInputDto) + paymentMethods?: InvoicePaymentMethodInputDto[]; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -373,6 +397,12 @@ export class UpdateInvoiceTemplateDto { @Type(() => InvoiceKeyPointInputDto) keyPoints?: InvoiceKeyPointInputDto[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => InvoicePaymentMethodInputDto) + paymentMethods?: InvoicePaymentMethodInputDto[]; + @IsOptional() @IsArray() @ValidateNested({ each: true }) diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 877d7d9..66c49cf 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -16,6 +16,7 @@ import { InvoiceAccountInputDto, InvoiceItemInputDto, InvoiceKeyPointInputDto, + InvoicePaymentMethodInputDto, InvoiceTemplateItemInputDto, ListInvoicesDto, UpdateInvoiceDto, @@ -250,12 +251,14 @@ export class InvoicesService { user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } }, items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }; private readonly invoiceTemplateInclude = { items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }; @@ -271,6 +274,18 @@ export class InvoicesService { }; } + private serializePaymentMethod(row: { + id: bigint; + text: string; + sortOrder: number; + }) { + return { + id: row.id.toString(), + text: row.text, + sortOrder: row.sortOrder, + }; + } + private serializeAccount(row: { id: bigint; bankName: string; @@ -343,6 +358,11 @@ export class InvoicesService { text: string; sortOrder: number; }>; + paymentMethods?: Array<{ + id: bigint; + text: string; + sortOrder: number; + }>; accounts?: Array<{ id: bigint; bankName: string; @@ -372,6 +392,10 @@ export class InvoicesService { includeNested && row.keyPoints ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) : undefined, + paymentMethods: + includeNested && row.paymentMethods + ? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm)) + : undefined, accounts: includeNested && row.accounts ? row.accounts.map((acc) => this.serializeAccount(acc)) @@ -488,6 +512,11 @@ export class InvoicesService { text: string; sortOrder: number; }>; + paymentMethods?: Array<{ + id: bigint; + text: string; + sortOrder: number; + }>; accounts?: Array<{ id: bigint; bankName: string; @@ -564,6 +593,10 @@ export class InvoicesService { includeNested && row.keyPoints ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) : undefined, + paymentMethods: + includeNested && row.paymentMethods + ? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm)) + : undefined, accounts: includeNested && row.accounts ? row.accounts.map((acc) => this.serializeAccount(acc)) @@ -581,6 +614,14 @@ export class InvoicesService { return { text }; } + private normalizePaymentMethodInput(paymentMethod: InvoicePaymentMethodInputDto) { + const text = paymentMethod.text.trim(); + if (!text) { + throw new BadRequestException('Each payment method requires text'); + } + return { text }; + } + private normalizeAccountInput(account: InvoiceAccountInputDto) { const bankName = account.bankName.trim(); if (!bankName) { @@ -793,6 +834,9 @@ export class InvoicesService { const items = dto.items.map((item) => this.normalizeTemplateItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); + const paymentMethods = (dto.paymentMethods ?? []).map((pm) => + this.normalizePaymentMethodInput(pm), + ); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const row = await this.prisma.invoiceTemplate.create({ @@ -819,6 +863,12 @@ export class InvoicesService { sortOrder: index, })), }, + paymentMethods: { + create: paymentMethods.map((pm, index) => ({ + text: pm.text, + sortOrder: index, + })), + }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, @@ -856,6 +906,10 @@ export class InvoicesService { dto.keyPoints !== undefined ? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp)) : null; + const replacePaymentMethods = + dto.paymentMethods !== undefined + ? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm)) + : null; const replaceAccounts = dto.accounts !== undefined ? dto.accounts.map((acc) => this.normalizeAccountInput(acc)) @@ -868,6 +922,9 @@ export class InvoicesService { if (replaceKeyPoints !== null) { await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } }); } + if (replacePaymentMethods !== null) { + await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } }); + } if (replaceAccounts !== null) { await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } }); } @@ -905,6 +962,16 @@ export class InvoicesService { }, } : {}), + ...(replacePaymentMethods !== null + ? { + paymentMethods: { + create: replacePaymentMethods.map((pm, index) => ({ + text: pm.text, + sortOrder: index, + })), + }, + } + : {}), ...(replaceAccounts !== null ? { accounts: { @@ -1116,6 +1183,9 @@ export class InvoicesService { const items = dto.items.map((item) => this.normalizeTemplateItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); + const paymentMethods = (dto.paymentMethods ?? []).map((pm) => + this.normalizePaymentMethodInput(pm), + ); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const row = await this.prisma.invoiceTemplate.create({ @@ -1143,6 +1213,12 @@ export class InvoicesService { sortOrder: index, })), }, + paymentMethods: { + create: paymentMethods.map((pm, index) => ({ + text: pm.text, + sortOrder: index, + })), + }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, @@ -1183,6 +1259,10 @@ export class InvoicesService { dto.keyPoints !== undefined ? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp)) : null; + const replacePaymentMethods = + dto.paymentMethods !== undefined + ? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm)) + : null; const replaceAccounts = dto.accounts !== undefined ? dto.accounts.map((acc) => this.normalizeAccountInput(acc)) @@ -1195,6 +1275,9 @@ export class InvoicesService { if (replaceKeyPoints !== null) { await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } }); } + if (replacePaymentMethods !== null) { + await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } }); + } if (replaceAccounts !== null) { await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } }); } @@ -1232,6 +1315,16 @@ export class InvoicesService { }, } : {}), + ...(replacePaymentMethods !== null + ? { + paymentMethods: { + create: replacePaymentMethods.map((pm, index) => ({ + text: pm.text, + sortOrder: index, + })), + }, + } + : {}), ...(replaceAccounts !== null ? { accounts: { @@ -1304,6 +1397,7 @@ export class InvoicesService { : null, items: serialized.items, keyPoints: serialized.keyPoints, + paymentMethods: serialized.paymentMethods, accounts: serialized.accounts, subtotal: serialized.subtotal, total: serialized.total, @@ -1465,6 +1559,9 @@ export class InvoicesService { const items = dto.items.map((item) => this.normalizeItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); + const paymentMethods = (dto.paymentMethods ?? []).map((pm) => + this.normalizePaymentMethodInput(pm), + ); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const invoiceTemplateId = dto.invoiceTemplateId?.trim() ? BigInt(dto.invoiceTemplateId) @@ -1514,6 +1611,12 @@ export class InvoicesService { sortOrder: index, })), }, + paymentMethods: { + create: paymentMethods.map((pm, index) => ({ + text: pm.text, + sortOrder: index, + })), + }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, @@ -1600,6 +1703,9 @@ export class InvoicesService { const items = dto.items.map((item) => this.normalizeItemInput(item)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); + const paymentMethods = (dto.paymentMethods ?? []).map((pm) => + this.normalizePaymentMethodInput(pm), + ); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); let userId: bigint | undefined; @@ -1629,6 +1735,7 @@ export class InvoicesService { const row = await this.prisma.$transaction(async (tx) => { await tx.invoiceItem.deleteMany({ where: { invoiceId } }); await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } }); + await tx.invoicePaymentMethod.deleteMany({ where: { invoiceId } }); await tx.invoiceAccount.deleteMany({ where: { invoiceId } }); return tx.invoice.update({ @@ -1657,6 +1764,12 @@ export class InvoicesService { sortOrder: index, })), }, + paymentMethods: { + create: paymentMethods.map((pm, index) => ({ + text: pm.text, + sortOrder: index, + })), + }, accounts: { create: accounts.map((acc, index) => ({ bankName: acc.bankName, diff --git a/src/legacy-mysql/legacy-source-s3.service.ts b/src/legacy-mysql/legacy-source-s3.service.ts index 7addb0e..5ff5605 100644 --- a/src/legacy-mysql/legacy-source-s3.service.ts +++ b/src/legacy-mysql/legacy-source-s3.service.ts @@ -60,13 +60,23 @@ export class LegacySourceS3Service implements OnModuleDestroy { const slug = slugHint.trim(); add(`${slug}_${oldBusinessId}`); add(`${slug.replace(/-/g, '')}_${oldBusinessId}`); + add(`${slug.replace(/_/g, '')}_${oldBusinessId}`); + add(`${slug.replace(/_/g, '-')}_${oldBusinessId}`); } - const listed = await this.listTopLevelPrefixes(); - for (const prefix of listed) { - if (prefix.endsWith(`_${oldBusinessId}`)) { - add(prefix); + // ListBucket may be denied on the old bucket; slug candidates + HeadObject still work. + try { + const listed = await this.listTopLevelPrefixes(); + for (const prefix of listed) { + if (prefix.endsWith(`_${oldBusinessId}`)) { + add(prefix); + } } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `Legacy S3 list prefixes failed for business ${oldBusinessId}; using slug candidates only: ${message}`, + ); } if (!candidates.length) { @@ -75,7 +85,8 @@ export class LegacySourceS3Service implements OnModuleDestroy { ); } - const sample = probes.slice(0, 12); + // Spread probes so early missing files don't hide a valid prefix. + const sample = this.pickProbeSample(probes, 12); let bestPrefix = candidates[0]; let bestHits = -1; @@ -96,6 +107,16 @@ export class LegacySourceS3Service implements OnModuleDestroy { } } + if (bestHits <= 0 && sample.length > 0) { + for (const prefix of candidates) { + if (await this.prefixHasAnyObject(prefix)) { + bestPrefix = prefix; + bestHits = 1; + break; + } + } + } + if (bestHits <= 0 && sample.length > 0) { throw new ServiceUnavailableException( `Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`, @@ -106,6 +127,35 @@ export class LegacySourceS3Service implements OnModuleDestroy { return bestPrefix; } + private pickProbeSample(items: T[], n: number): T[] { + if (items.length <= n) return [...items]; + const out: T[] = []; + const seen = new Set(); + for (let i = 0; i < n; i++) { + const idx = Math.floor((i * (items.length - 1)) / Math.max(n - 1, 1)); + if (seen.has(idx)) continue; + seen.add(idx); + out.push(items[idx]); + } + return out; + } + + private async prefixHasAnyObject(prefix: string): Promise { + const { client, bucket } = this.getClient(); + try { + const res = await client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: `${prefix}/`, + MaxKeys: 1, + }), + ); + return (res.Contents?.length ?? 0) > 0; + } catch { + return false; + } + } + async getObjectBuffer(key: string): Promise { const { client, bucket } = this.getClient(); try {