* feat: add hosted billing gate and backlinks metering * refine hosted billing onboarding flow * fix: align Autumn credit billing flows * refactor: simplify Autumn billing flow * refactor: narrow backlinks billing context * refactor: require billing identity for backlinks profiles * refactor: colocate backlinks billing flow * clean * refactor: centralize DataForSEO billing client * refactor: simplify dataforseo billing flow * chore: fix ci check lint issues * fix: stabilize backlinks chart sizing Measure chart container width before rendering the backlinks charts so Recharts does not mount at 0x0 inside responsive layouts. * docs: document hosted DataForSEO metering Capture the Autumn credit model and require hosted code to go through the metered client path so provider usage is harder to bypass. * fix: enforce hosted billing access checks * fix: preserve hosted billing subscription access * fix: handle hosted billing UI failures
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
import { createServerFn } from "@tanstack/react-start";
|
|
import {
|
|
buildBacklinksDisabledAccessStatus,
|
|
buildVerifiedBacklinksAccessStatus,
|
|
getBacklinksAccessStatus,
|
|
setBacklinksAccessStatus,
|
|
} from "@/server/features/backlinks/backlinksAccess";
|
|
import { AppError } from "@/server/lib/errors";
|
|
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
|
import { backlinksProjectSchema } from "@/types/schemas/backlinks";
|
|
|
|
const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000;
|
|
|
|
export const getBacklinksAccessSetupStatus = createServerFn({
|
|
method: "GET",
|
|
})
|
|
.middleware(requireProjectContext)
|
|
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
|
|
.handler(async () => getBacklinksAccessStatus());
|
|
|
|
export const testBacklinksAccess = createServerFn({
|
|
method: "POST",
|
|
})
|
|
.middleware(requireProjectContext)
|
|
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
|
|
.handler(async ({ context }) => {
|
|
if (await isHostedServerAuthMode()) {
|
|
// Hosted deployments do not run the manual DataForSEO access test here;
|
|
// backlinks access is treated as platform-managed in this mode.
|
|
return getBacklinksAccessStatus();
|
|
}
|
|
|
|
const cachedStatus = await getBacklinksAccessStatus();
|
|
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) {
|
|
return cachedStatus;
|
|
}
|
|
|
|
const checkedAt = new Date().toISOString();
|
|
const dataforseo = createDataforseoClient({
|
|
organizationId: context.organizationId,
|
|
userEmail: context.userEmail,
|
|
});
|
|
|
|
try {
|
|
await dataforseo.backlinks.summary({
|
|
target: "dataforseo.com",
|
|
includeSubdomains: true,
|
|
includeIndirectLinks: true,
|
|
excludeInternalBacklinks: true,
|
|
status: "live",
|
|
});
|
|
|
|
const status = buildVerifiedBacklinksAccessStatus(checkedAt);
|
|
await setBacklinksAccessStatus(status);
|
|
return status;
|
|
} catch (error) {
|
|
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
|
|
const status = buildBacklinksDisabledAccessStatus(
|
|
checkedAt,
|
|
error.code,
|
|
);
|
|
await setBacklinksAccessStatus(status);
|
|
return status;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
function isRecentVerifiedBacklinksAccessCheck(
|
|
status: Awaited<ReturnType<typeof getBacklinksAccessStatus>>,
|
|
) {
|
|
if (!status.enabled || !status.lastCheckedAt) {
|
|
return false;
|
|
}
|
|
|
|
const lastChecked = Date.parse(status.lastCheckedAt);
|
|
if (Number.isNaN(lastChecked)) {
|
|
return false;
|
|
}
|
|
|
|
return Date.now() - lastChecked < BACKLINKS_ACCESS_CHECK_COOLDOWN_MS;
|
|
}
|