dev #118

Merged
root merged 5 commits from dev into main 2026-06-09 18:12:44 +03:00
11 changed files with 272 additions and 8 deletions

View File

@@ -1,11 +1,19 @@
import { NotFoundException } from "@nestjs/common";
import * as Sentry from "@sentry/nestjs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CategoriesService } from "./categories.service";
// The catalog-degradation reporter calls Sentry.captureMessage; stub it so we can
// assert silent UX failures are reported without hitting the real SDK.
vi.mock("@sentry/nestjs", () => ({ captureMessage: vi.fn() }));
function createService(db: any) {
const redis = {
getJson: vi.fn().mockResolvedValue(null),
setJson: vi.fn().mockResolvedValue(undefined),
// Used by the Sentry degradation dedup; default to "not seen" so the path runs.
exists: vi.fn().mockResolvedValue(false),
set: vi.fn().mockResolvedValue(undefined),
};
const pl24Service = {
getCategories: vi.fn().mockResolvedValue([]),
@@ -290,15 +298,27 @@ describe("CategoriesService", () => {
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
const { service, redis } = createService(db);
// Drill comes back empty (transient upstream failure).
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
(Sentry.captureMessage as ReturnType<typeof vi.fn>).mockClear();
const result = await service.getCategoryWithParts("psa2");
expect(service.getChildren).toHaveBeenCalledWith("psa2");
expect((result as { loadError?: boolean }).loadError).toBe(true);
expect((result as { children?: unknown }).children).toBeUndefined();
// The silent loadError is reported to Sentry (deduped via redis.set) so it
// can't go unnoticed the way serkan's empty catalog did.
expect(redis.set).toHaveBeenCalledWith(
expect.stringContaining("sentry:cat-degraded:drill-load-error:"),
"1",
3600,
);
expect(Sentry.captureMessage).toHaveBeenCalledTimes(1);
expect((Sentry.captureMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain(
"drill-load-error",
);
});
});

View File

@@ -1,5 +1,9 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import {
type CatalogDegradationKind,
reportCatalogDegradation,
} from "../common/catalog-degradation";
import { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
@@ -438,6 +442,19 @@ export class CategoriesService {
// it self-heals on the next request after the source recovers, while still
// throttling re-decode attempts during a real outage.
await this.redis.setJson(cacheKey, tree, tree.length > 0 ? 3600 : 60);
// Decoded vehicle but the catalog tree is empty — "model geldi ama kategori
// yok". Another silent (HTTP 200, no throw) failure; surface persistent
// upstream/seed gaps to Sentry so they get triaged instead of overlooked.
if (tree.length === 0) {
await this.reportDegradationOnce("empty-tree", vehicleId, {
vehicleId,
vin: vehicle.vin,
brand: vehicle.brandName,
model: vehicle.model,
source: vehicle.source,
});
}
return tree;
}
@@ -899,9 +916,59 @@ export class CategoriesService {
// Attach the full ancestor trail so the client can render a complete,
// reliable breadcrumb regardless of what's in its tree cache.
const ancestors = await this.getAncestors(categoryId);
// A loadError means the drill / parts fetch failed and the user is staring
// at an empty "couldn't load" panel instead of parts — a silent UX failure
// (HTTP 200, nothing thrown) the exception filter never sees. Surface it.
if ((result as { loadError?: boolean }).loadError) {
await this.reportDegradationOnce("drill-load-error", categoryId);
}
return { ...result, ancestors };
}
/**
* Report a silent catalog UX failure to Sentry, deduped to at most once per
* hour per vehicle/category (Redis) so a broken catalog can't flood the issue
* stream. With no ctx, dedupId is treated as a categoryId and the vehicle is
* looked up for context. Never throws into the request path.
*/
private async reportDegradationOnce(
kind: CatalogDegradationKind,
dedupId: string,
ctx?: Parameters<typeof reportCatalogDegradation>[1],
): Promise<void> {
try {
const dedupKey = `sentry:cat-degraded:${kind}:${dedupId}`;
if (await this.redis.exists(dedupKey)) return;
await this.redis.set(dedupKey, "1", 3600);
if (ctx) {
reportCatalogDegradation(kind, ctx);
return;
}
const [cat] = await this.db
.select()
.from(categories)
.where(eq(categories.id, dedupId))
.limit(1);
const [veh] = cat?.vehicleId
? await this.db.select().from(vehicles).where(eq(vehicles.id, cat.vehicleId)).limit(1)
: [];
reportCatalogDegradation(kind, {
vehicleId: cat?.vehicleId ?? null,
vin: veh?.vin ?? null,
brand: veh?.brandName ?? null,
model: veh?.model ?? null,
source: cat?.source ?? null,
categoryId: dedupId,
categoryName: cat?.name ?? null,
linkPath: cat?.linkPath ?? null,
});
} catch {
// Telemetry must never break the request.
}
}
private async getCategoryWithPartsInner(categoryId: string) {
const [category] = await this.db
.select()

View File

@@ -0,0 +1,58 @@
import * as Sentry from "@sentry/nestjs";
/**
* A *silent* catalog UX failure: the request succeeds (HTTP 200) but the user
* gets a degraded result — an empty parts panel / retryable load error, or an
* empty category tree on a vehicle that decoded fine. Because nothing throws,
* the global exception filter never sees these, so they go unnoticed — exactly
* how serkan's "şase girdim, model var ama parça yok" sat invisible. We report
* them to Sentry explicitly so they surface and get triaged like real errors.
*/
export type CatalogDegradationKind =
| "empty-tree" // vehicle decoded but the category tree came back empty
| "drill-load-error"; // a category drill/parts fetch failed → empty/retry panel, not parts
export interface CatalogDegradationContext {
vehicleId?: string | null;
vin?: string | null;
brand?: string | null;
model?: string | null;
source?: string | null;
categoryId?: string | null;
categoryName?: string | null;
linkPath?: string | null;
}
/**
* Send a catalog degradation to Sentry as a warning. Fingerprinted by
* kind + source + brand so every instance of one failure mode collapses into a
* single, countable Sentry issue ("drill-load-error · pl24/Ford — 1.2k events,
* 80 users") rather than thousands of unique events. Call sites must dedup
* (e.g. a short Redis TTL per vehicle/category) before invoking this, and must
* never let it throw into the request path.
*/
export function reportCatalogDegradation(
kind: CatalogDegradationKind,
ctx: CatalogDegradationContext,
): void {
const source = ctx.source ?? "unknown";
const brand = ctx.brand ?? "unknown";
Sentry.captureMessage(`catalog degraded: ${kind} (${source}/${brand})`, {
level: "warning",
tags: {
catalog_degradation: kind,
catalog_source: source,
catalog_brand: brand,
},
// Group by failure mode, not by individual vehicle/category.
fingerprint: ["catalog-degradation", kind, source, brand.toLowerCase()],
extra: {
vehicleId: ctx.vehicleId ?? null,
vin: ctx.vin ?? null,
model: ctx.model ?? null,
categoryId: ctx.categoryId ?? null,
categoryName: ctx.categoryName ?? null,
linkPath: ctx.linkPath ?? null,
},
});
}

View File

@@ -1,5 +1,5 @@
import { Job } from "bullmq";
import { and, eq, lt } from "drizzle-orm";
import { and, eq, inArray, lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userBrands, userSubscriptions } from "../../database/schema/core";
@@ -13,11 +13,20 @@ export async function processSubscriptionExpiry(
const now = new Date();
// Find active subscriptions where endDate has passed
// Find active OR trial subscriptions whose endDate has passed.
// NOTE: "trial" was previously omitted here, so trials never expired — their
// status stayed "trial" forever past end_date, and every access gate keys off
// status, so those users kept full access for free (revenue leak). `lt` skips
// NULL end_date rows, so perpetual/active subs without an end_date are untouched.
const expiredSubs = await db
.select({ id: userSubscriptions.id, userId: userSubscriptions.userId })
.from(userSubscriptions)
.where(and(eq(userSubscriptions.status, "active"), lt(userSubscriptions.endDate, now)));
.where(
and(
inArray(userSubscriptions.status, ["active", "trial"]),
lt(userSubscriptions.endDate, now),
),
);
if (expiredSubs.length === 0) {
console.log("[subscription-expiry] No expired subscriptions found");

View File

@@ -96,6 +96,63 @@ export class MetaCapiService {
this.logger.warn(`Meta CAPI CompleteRegistration error: ${(err as Error).message}`);
}
}
/**
* Send a server-side `Purchase` — the realized-revenue signal. This is what lets
* Meta optimize toward (and build lookalikes of) accounts that actually PAY, not
* just trial — the single highest-value signal for a B2B funnel where trials are
* cheap but paid is rare. Fired from the activation chokepoint (Stripe webhook +
* EFT/manual), so all paid revenue is sent regardless of method. The webhook has
* no browser context (no fbp/fbc/ip), but hashed-email Advanced Matching still
* lets Meta attribute. event_id = purchase_<subscriptionId> dedupes a browser
* Purchase. Fail-open: never throws.
*/
async sendPurchase(input: {
userId: string;
email?: string | null;
subscriptionId: string;
valueKurus: number;
currency?: string;
}): Promise<void> {
if (!this.enabled) return;
try {
const userData: Record<string, unknown> = {};
if (input.email) userData.em = [sha256(input.email.trim().toLowerCase())];
const body = {
data: [
{
event_name: "Purchase",
event_time: Math.floor(Date.now() / 1000),
event_id: `purchase_${input.subscriptionId}`,
action_source: "website",
user_data: userData,
custom_data: {
currency: input.currency ?? "TRY",
value: input.valueKurus / 100,
},
},
],
...(this.testEventCode ? { test_event_code: this.testEventCode } : {}),
};
const res = await fetch(
`https://graph.facebook.com/${GRAPH_VERSION}/${this.pixelId}/events?access_token=${this.accessToken}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
const text = await res.text().catch(() => "");
this.logger.warn(`Meta CAPI Purchase failed (${res.status}): ${text.slice(0, 300)}`);
}
} catch (err) {
this.logger.warn(`Meta CAPI Purchase error: ${(err as Error).message}`);
}
}
}
function sha256(value: string): string {

View File

@@ -222,6 +222,12 @@ export class StripeService {
this.logger.debug(`Unhandled Stripe event type: ${event.type}`);
}
// Await delivery of any conversion events captured during webhook processing
// (payment_success / payment_failed / subscription_activated). Without this,
// the webhook returns and the fire-and-forget flush is abandoned, so these
// events landed in the DB but never in PostHog.
await this.posthog.flush();
return { received: true };
}

View File

@@ -93,6 +93,24 @@ export class PostHogService implements OnModuleDestroy {
this.capture(event, { ...properties, $user_id: userId }, userId);
}
/**
* Force-deliver queued events and await the network send. MUST be awaited at
* critical conversion chokepoints (Stripe webhook, subscription activation):
* those run in a webhook/short request that returns immediately, so the default
* fire-and-forget flush was dropping the events — `payment_success`,
* `payment_failed`, and `subscription_activated` were written to the DB but never
* reached PostHog (whereas `payment_initiated`, fired in a normal request, did).
* Best-effort: never throws, so analytics can't break a payment.
*/
async flush(): Promise<void> {
if (!this.captureEnabled || !this.client) return;
try {
await this.client.flush();
} catch (err) {
this.logger.warn(`PostHog flush failed: ${(err as Error).message}`);
}
}
// ──────────────────────────────────────────────────────────────────────────
// Feature flags — server-side, local evaluation
// ──────────────────────────────────────────────────────────────────────────

View File

@@ -56,8 +56,13 @@ function createMockDb(overrides: Record<string, unknown> = {}) {
* Creates the service with a given mock db injected via reflection.
*/
function createService(db: unknown): SubscriptionsService {
const posthog = { captureForUser: vi.fn(), capture: vi.fn() };
const service = new SubscriptionsService(db as any, posthog as any);
const posthog = {
captureForUser: vi.fn(),
capture: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
};
const metaCapi = { sendPurchase: vi.fn().mockResolvedValue(undefined) };
const service = new SubscriptionsService(db as any, posthog as any, metaCapi as any);
return service;
}

View File

@@ -15,6 +15,7 @@ import {
userSubscriptions,
users,
} from "../database/schema/core";
import { MetaCapiService } from "../meta-capi/meta-capi.service";
import { PostHogService } from "../posthog/posthog.service";
@Injectable()
@@ -22,6 +23,7 @@ export class SubscriptionsService {
constructor(
@Inject(DATABASE) private db: Database,
private posthog: PostHogService,
private metaCapi: MetaCapiService,
) {}
/**
@@ -192,6 +194,28 @@ export class SubscriptionsService {
referral_credit_days: creditDays,
});
// Realized-revenue signal to Meta (server-side Purchase) so the ad algorithm
// optimizes toward — and builds lookalikes of — accounts that actually PAY,
// not just trial. Shared chokepoint → covers Stripe + EFT. Awaited so the
// event ships before the short webhook/request returns. Fail-open inside.
const [capiUser] = await this.db
.select({ email: users.email })
.from(users)
.where(eq(users.id, sub.userId))
.limit(1);
await this.metaCapi.sendPurchase({
userId: sub.userId,
email: capiUser?.email,
subscriptionId,
valueKurus: priceKurus,
currency: "TRY",
});
// Await delivery: activateSubscription is the shared chokepoint for Stripe
// (webhook) AND EFT/manual activation, both of which run in short requests
// whose fire-and-forget flush was dropping this revenue event.
await this.posthog.flush();
return updated;
}

View File

@@ -21,7 +21,7 @@ export interface Subscription {
updatedAt: Date;
}
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
export type SubscriptionStatus = "pending" | "active" | "trial" | "cancelled" | "expired";
export interface UserBrand {
id: string;

View File

@@ -28,4 +28,4 @@ export interface UserSubscriptionSummary {
expiresAt: Date;
}
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
export type SubscriptionStatus = "pending" | "active" | "trial" | "cancelled" | "expired";