From a5f2f8d63b76bf8ead9e89ceb1d2663fc9df534d Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 30 Aug 2026 14:47:10 +0330 Subject: [PATCH] Fix SSL list badges, labeled AI prompt URLs, and product-category migrate. Storefront SSL now follows apex expiry instead of requiring www, prompt links include domain and prompt name, and extra color variations merge instead of aborting migrate. Co-authored-by: Cursor --- src/ai-prompts/ai-prompts.controller.ts | 18 ++++ src/ai-prompts/ai-prompts.service.ts | 105 +++++++++++++++---- src/business-admin/legacy-migrate.service.ts | 69 ++++++++---- src/domain-admin/domain-admin.service.ts | 47 ++++----- src/internal-ssl/internal-ssl.controller.ts | 2 +- src/internal-ssl/internal-ssl.service.ts | 54 +++++----- 6 files changed, 208 insertions(+), 87 deletions(-) diff --git a/src/ai-prompts/ai-prompts.controller.ts b/src/ai-prompts/ai-prompts.controller.ts index 0f013d5..08690d8 100644 --- a/src/ai-prompts/ai-prompts.controller.ts +++ b/src/ai-prompts/ai-prompts.controller.ts @@ -99,6 +99,24 @@ export class AiPromptsController { return this.service.removeDomainPrompt(domainId, promptId, user); } + @Get('public/ai-prompts/:host/:name/:prompt/:publicId') + @Header('Content-Type', 'text/plain; charset=utf-8') + getPublicFull(@Param('publicId') publicId: string) { + return this.service.getPublicText(publicId); + } + + @Get('public/ai-prompts/:host/:name/:publicId') + @Header('Content-Type', 'text/plain; charset=utf-8') + getPublicLabeled(@Param('publicId') publicId: string) { + return this.service.getPublicText(publicId); + } + + @Get('public/ai-prompts/:host/:publicId') + @Header('Content-Type', 'text/plain; charset=utf-8') + getPublicHost(@Param('publicId') publicId: string) { + return this.service.getPublicText(publicId); + } + @Get('public/ai-prompts/:publicId') @Header('Content-Type', 'text/plain; charset=utf-8') getPublic(@Param('publicId') publicId: string) { diff --git a/src/ai-prompts/ai-prompts.service.ts b/src/ai-prompts/ai-prompts.service.ts index c63a13d..cd59412 100644 --- a/src/ai-prompts/ai-prompts.service.ts +++ b/src/ai-prompts/ai-prompts.service.ts @@ -40,11 +40,43 @@ export class AiPromptsService { throw new BadRequestException('Unable to allocate a public prompt id'); } - private publicUrl(publicId: string): string { + private slugSegment(value: string, fallback = 'site'): string { + const slug = value + .trim() + .toLowerCase() + .replace(/['"]/g, '') + .replace(/[^a-z0-9\u0600-\u06ff]+/gi, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return slug || fallback; + } + + private publicUrl( + publicId: string, + labels?: { + host?: string | null; + name?: string | null; + promptName?: string | null; + }, + ): string { const base = this.config.get('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') || 'https://api.meshkee.com'; - return `${base}/api/v1/public/ai-prompts/${publicId}`; + const host = labels?.host?.trim().toLowerCase(); + const nameSlug = labels?.name?.trim() + ? this.slugSegment(labels.name) + : ''; + const promptSlug = labels?.promptName?.trim() + ? this.slugSegment(labels.promptName) + : ''; + const parts = [ + host ? encodeURIComponent(host) : '', + nameSlug ? encodeURIComponent(nameSlug) : '', + promptSlug ? encodeURIComponent(promptSlug) : '', + publicId, + ].filter(Boolean); + return `${base}/api/v1/public/ai-prompts/${parts.join('/')}`; } private serializeTemplate(row: { @@ -63,17 +95,23 @@ export class AiPromptsService { }; } - private serializeDomainPrompt(row: { - id: bigint; - domainId: bigint; - sourceTemplateId: bigint | null; - name: string; - body: string; - publicId: string; - sortOrder: number; - createdAt: Date; - updatedAt: Date; - }) { + private serializeDomainPrompt( + row: { + id: bigint; + domainId: bigint; + sourceTemplateId: bigint | null; + name: string; + body: string; + publicId: string; + sortOrder: number; + createdAt: Date; + updatedAt: Date; + }, + labels?: { + host?: string | null; + name?: string | null; + }, + ) { return { id: row.id.toString(), domainId: row.domainId.toString(), @@ -81,13 +119,29 @@ export class AiPromptsService { name: row.name, body: row.body, publicId: row.publicId, - publicUrl: this.publicUrl(row.publicId), + publicUrl: this.publicUrl(row.publicId, { + host: labels?.host, + name: labels?.name, + promptName: row.name, + }), sortOrder: row.sortOrder, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } + private async domainLabels(domainId: bigint) { + const domain = await this.prisma.domain.findUnique({ + where: { id: domainId }, + select: { + host: true, + business: { select: { name: true } }, + }, + }); + if (!domain) return undefined; + return { host: domain.host, name: domain.business.name }; + } + async list(actor: AuthUser) { await this.assertSuperAdmin(actor); const items = await this.prisma.aiPromptTemplate.findMany({ @@ -163,7 +217,12 @@ export class AiPromptsService { domainId: domain.id.toString(), host: domain.host, businessName: domain.business.name, - items: items.map((row) => this.serializeDomainPrompt(row)), + items: items.map((row) => + this.serializeDomainPrompt(row, { + host: domain.host, + name: domain.business.name, + }), + ), }; } @@ -176,7 +235,11 @@ export class AiPromptsService { const domainId = BigInt(domainIdRaw); const domain = await this.prisma.domain.findUnique({ where: { id: domainId }, - select: { id: true }, + select: { + id: true, + host: true, + business: { select: { name: true } }, + }, }); if (!domain) throw new NotFoundException('Website not found'); @@ -205,7 +268,10 @@ export class AiPromptsService { sortOrder: (maxSort._max.sortOrder ?? -1) + 1, }, }); - return this.serializeDomainPrompt(created); + return this.serializeDomainPrompt(created, { + host: domain.host, + name: domain.business.name, + }); } async updateDomainPrompt( @@ -230,7 +296,10 @@ export class AiPromptsService { ...(dto.body !== undefined ? { body: dto.body.trim() } : {}), }, }); - return this.serializeDomainPrompt(updated); + return this.serializeDomainPrompt( + updated, + await this.domainLabels(domainId), + ); } async removeDomainPrompt( diff --git a/src/business-admin/legacy-migrate.service.ts b/src/business-admin/legacy-migrate.service.ts index 0dd793c..b0da1c3 100644 --- a/src/business-admin/legacy-migrate.service.ts +++ b/src/business-admin/legacy-migrate.service.ts @@ -2869,13 +2869,20 @@ export class LegacyMigrateService { } // Load already-migrated variations to stay idempotent. + // Meshkee allows at most one color and one size variation per category. const existingVariations = await this.prisma.categoryVariation.findMany({ where: { businessId }, - select: { id: true, categoryId: true, name: true }, + select: { id: true, categoryId: true, name: true, variationType: true }, }); const existingVarSet = new Set( existingVariations.map((v) => `${v.categoryId}:${v.name}`), ); + const variationIdByType = new Map(); + for (const v of existingVariations) { + if (v.variationType === 'color' || v.variationType === 'size') { + variationIdByType.set(`${v.categoryId}:${v.variationType}`, v.id); + } + } for (const attr of attributes) { const newCategoryId = oldToNew.get(attr.attributable_id); @@ -2883,25 +2890,51 @@ export class LegacyMigrateService { const variationName = (attr.name ?? '').trim() || 'Variation'; const varKey = `${newCategoryId}:${variationName}`; - if (existingVarSet.has(varKey)) continue; - const variationType = this.legacyAttributeTypeToVariationType( attr.attribute_type, ); + const typeKey = + variationType === 'color' || variationType === 'size' + ? `${newCategoryId}:${variationType}` + : null; - const variation = await this.prisma.categoryVariation.create({ - data: { - businessId, - categoryId: newCategoryId, - name: variationName, - variationType, - sortOrder: 0, - }, - select: { id: true }, - }); - existingVarSet.add(varKey); + let variationId = typeKey ? variationIdByType.get(typeKey) : undefined; + + if (!variationId && existingVarSet.has(varKey)) continue; + + if (!variationId) { + try { + const variation = await this.prisma.categoryVariation.create({ + data: { + businessId, + categoryId: newCategoryId, + name: variationName, + variationType, + sortOrder: 0, + }, + select: { id: true }, + }); + variationId = variation.id; + existingVarSet.add(varKey); + if (typeKey) variationIdByType.set(typeKey, variationId); + } catch { + // Unique: one color/size per category, or custom name already exists. + if (typeKey) { + const existing = await this.prisma.categoryVariation.findFirst({ + where: { categoryId: newCategoryId, variationType }, + select: { id: true }, + }); + variationId = existing?.id; + if (variationId) variationIdByType.set(typeKey, variationId); + } + if (!variationId) continue; + } + } const options = valuesByAttr.get(attr.id) ?? []; + const existingOptionCount = await this.prisma.categoryVariationOption.count( + { where: { variationId } }, + ); for (const [idx, opt] of options.entries()) { const label = (opt.name ?? '').trim() || 'Option'; const value = @@ -2912,14 +2945,12 @@ export class LegacyMigrateService { try { await this.prisma.categoryVariationOption.create({ data: { - variationId: variation.id, + variationId, label, value, colorHex: - variationType === 'color' - ? this.guessColorHex(label) - : null, - sortOrder: idx, + variationType === 'color' ? this.guessColorHex(label) : null, + sortOrder: existingOptionCount + idx, }, }); } catch { diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index c2881e7..cdea774 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -98,12 +98,14 @@ export class DomainAdminService { const now = Date.now(); const items = rows.map((row) => { - const certStillValid = - !row.sslExpiresAt || row.sslExpiresAt.getTime() > now; + const expiryMs = row.sslExpiresAt?.getTime() ?? null; + const certStillValid = expiryMs == null || expiryMs > now; + // Prefer stored notAfter when present (daily probe is only a writer). + const sslEnabled = + expiryMs != null ? expiryMs > now : row.sslEnabled && certStillValid; return { ...row, - // List badge: issued/probed OK and cert not past notAfter. - sslEnabled: row.sslEnabled && certStillValid, + sslEnabled, sslExpiresAt: row.sslExpiresAt, }; }); @@ -208,8 +210,9 @@ export class DomainAdminService { ? domain.host : `www.${domain.host}`; const wwwProbe = await probeTlsHostDetailed(wwwHost); - const ok = apexProbe.ok && wwwProbe.ok; - if (ok) { + const apexOk = apexProbe.ok; + const bothOk = apexOk && wwwProbe.ok; + if (apexOk) { const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe); await this.prisma.domain.update({ where: { id: domain.id }, @@ -218,10 +221,8 @@ export class DomainAdminService { ...(sslExpiresAt ? { sslExpiresAt } : {}), }, }); - continue; - } - - if (domain.sslEnabled) { + if (bothOk) continue; + } else if (domain.sslEnabled) { await this.prisma.domain.update({ where: { id: domain.id }, data: { sslEnabled: false, sslExpiresAt: null }, @@ -253,23 +254,23 @@ export class DomainAdminService { ? domain.host : `www.${domain.host}`; const wwwProbe = await probeTlsHostDetailed(wwwHost); - const wwwOk = apexProbe.ok && wwwProbe.ok; - const sslExpiresAt = wwwOk + const apexOk = apexProbe.ok; + const sslExpiresAt = apexOk ? earliestTlsExpiry(apexProbe, wwwProbe) : null; await this.prisma.domain.update({ where: { id: domain.id }, data: { - sslEnabled: wwwOk, + sslEnabled: apexOk, sslExpiresAt, }, }); - if (wwwOk) { + if (apexOk) { issued.push(domain.host); } else { failed.push({ host: domain.host, - error: 'Certbot finished but TLS probe still failed (apex or www)', + error: 'Certbot finished but TLS probe still failed on apex', }); } } catch (err) { @@ -446,22 +447,20 @@ export class DomainAdminService { } } - const storefrontOk = apexOk && wwwOk; - // Re-probe apex/www once more when already-ok so we persist current cert expiry. - if (storefrontOk && (!apexProbe.expiresAt || !wwwProbe.expiresAt)) { + // List badge follows apex. www can still fail (no DNS) without marking Invalid. + if (apexOk && !apexProbe.expiresAt) { apexProbe = await probeTlsHostDetailed(apex); - wwwProbe = await probeTlsHostDetailed(wwwHost); apexOk = apexProbe.ok; - wwwOk = wwwProbe.ok; } - const sslExpiresAt = - apexOk && wwwOk ? earliestTlsExpiry(apexProbe, wwwProbe) : null; + const sslExpiresAt = apexOk + ? earliestTlsExpiry(apexProbe, wwwOk ? wwwProbe : apexProbe) + : null; const updated = await this.prisma.domain.update({ where: { id: domainId }, data: { - sslEnabled: storefrontOk && apexOk && wwwOk, - sslExpiresAt: storefrontOk && apexOk && wwwOk ? sslExpiresAt : null, + sslEnabled: apexOk, + sslExpiresAt: apexOk ? sslExpiresAt : null, }, }); diff --git a/src/internal-ssl/internal-ssl.controller.ts b/src/internal-ssl/internal-ssl.controller.ts index 4618ac7..8ecf43d 100644 --- a/src/internal-ssl/internal-ssl.controller.ts +++ b/src/internal-ssl/internal-ssl.controller.ts @@ -19,7 +19,7 @@ export class InternalSslController { return this.service.listApiHosts(); } - /** Manually probe apex hosts and sync domains.ssl_enabled. */ + /** Manually probe apex hosts and sync domains.ssl_enabled / ssl_expires_at. */ @Post('refresh-status') refreshStatus() { return this.service.refreshSslStatuses('manual'); diff --git a/src/internal-ssl/internal-ssl.service.ts b/src/internal-ssl/internal-ssl.service.ts index 2db01a8..db9bf1d 100644 --- a/src/internal-ssl/internal-ssl.service.ts +++ b/src/internal-ssl/internal-ssl.service.ts @@ -85,8 +85,9 @@ export class InternalSslService { } /** - * Occasional safety probe (once a day). Issue SSL writes ssl_expires_at and - * is the main source of truth for the list badge between probes. + * Occasional safety probe (once a day). Apex TLS is the list source of + * truth; a failed probe does not wipe ssl_expires_at while that date is + * still in the future. Issue SSL also writes ssl_expires_at. */ @Cron(CronExpression.EVERY_DAY_AT_4AM) async refreshSslStatusesCron() { @@ -125,42 +126,45 @@ export class InternalSslService { } } - const hostsToProbe = domains.flatMap((d) => { - const apex = d.host.trim().toLowerCase(); - if (!apex) return []; - const www = apex.startsWith('www.') ? apex : `www.${apex}`; - return [apex, www]; - }); + // Apex is the list badge. www is optional — missing www DNS used to + // mark every row Invalid and wipe ssl_expires_at. + const hostsToProbe = domains + .map((d) => d.host.trim().toLowerCase()) + .filter(Boolean); const sslByHost = await probeTlsHostsDetailed(hostsToProbe); for (const domain of domains) { const hostKey = domain.host.trim().toLowerCase(); - const wwwKey = hostKey.startsWith('www.') ? hostKey : `www.${hostKey}`; const apexResult = sslByHost.get(hostKey) ?? { ok: false, expiresAt: null, }; - const wwwResult = sslByHost.get(wwwKey) ?? { - ok: false, - expiresAt: null, - }; - const liveOk = apexResult.ok && wwwResult.ok; - const sslExpiresAt = liveOk - ? earliestTlsExpiry(apexResult, wwwResult) - : null; + const storedExpiry = domain.sslExpiresAt; + const storedStillValid = + !!storedExpiry && storedExpiry.getTime() > now.getTime(); - const nextEnabled = liveOk; - const nextExpires = - liveOk && sslExpiresAt - ? sslExpiresAt - : liveOk - ? domain.sslExpiresAt - : null; + let nextEnabled: boolean; + let nextExpires: Date | null; + + if (apexResult.ok) { + nextEnabled = true; + nextExpires = earliestTlsExpiry(apexResult) ?? storedExpiry; + } else if (storedStillValid) { + // Failed probe must not clear a cert that is still in date. + nextEnabled = true; + nextExpires = storedExpiry; + this.logger.warn( + `SSL status ${domain.host}: live probe failed; keeping stored expiry ${storedExpiry.toISOString()}`, + ); + } else { + nextEnabled = false; + nextExpires = null; + } const enabledChanged = nextEnabled !== domain.sslEnabled; const expiryChanged = (nextExpires?.getTime() ?? null) !== - (domain.sslExpiresAt?.getTime() ?? null); + (storedExpiry?.getTime() ?? null); if (!enabledChanged && !expiryChanged) continue;