mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:25:33 -04:00
refactor: unify health-severity logic, add live clock ticking, and drop polar/downgrade wrapper
- Extracted `getHealthSeverity` and `getDaysUntilExpiry` into `dashboard-utils.ts` as shared exports so `DomainHealthBadge`, `getHealthAccent`, and the table sort column all classify a domain identically; the badge, the health filter, and the sort order can no longer disagree on which bucket a domain belongs to. - Added `EXPIRING_CRITICAL_DAYS = 7` to constants, tying the badge's red threshold to the `domain_expiry_7d` notification so the badge and the expiry email always agree on the cutoff. - `useHydratedNow` now advances the shared clock every 60 seconds via `setInterval` after the first hydration, so health badges self-refresh on a long-lived tab without a page reload; the interval starts when the first subscriber attaches and is cleared when the last one unmounts. Added a `pinned` flag so `resetHydratedNow` in tests locks the clock and no subsequent tick can overwrite it. - Deleted `packages/polar/src/downgrade.ts`; it was a single-line re-export of `downgradeToFree` — the workflow and the handlers now import that function directly from `@domainstack/db/queries/user-subscription` and the `./downgrade` export entry has been removed from the polar package manifest.
This commit is contained in:
@@ -348,7 +348,7 @@ export const DashboardGridCard = memo(function DashboardGridCard({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-xl transition-all duration-150",
|
||||
"pointer-events-none absolute inset-0 rounded-xl transition duration-150",
|
||||
selected ? "ring-2 ring-primary/60 ring-offset-2 ring-offset-background" : "ring-0",
|
||||
)}
|
||||
aria-hidden
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ScreenshotPopover } from "@/components/domain/screenshot-popover";
|
||||
import { Favicon } from "@/components/icons/favicon";
|
||||
import { useIsDomainSelected, useToggleDomainSelection } from "@/hooks/use-dashboard-selection";
|
||||
import type { DashboardTableFeatures } from "@/lib/dashboard-table-features";
|
||||
import { getHealthSeverity, type HealthSeverity } from "@/lib/dashboard-utils";
|
||||
import type { TrackedDomainWithDetails, VerificationMethod } from "@domainstack/types";
|
||||
import { Button } from "@domainstack/ui/button";
|
||||
import { Checkbox } from "@domainstack/ui/checkbox";
|
||||
@@ -81,6 +82,14 @@ export const HIDEABLE_COLUMNS = (
|
||||
] as const satisfies readonly (keyof typeof COLUMN_HEADERS)[]
|
||||
).map((id) => ({ id, header: COLUMN_HEADERS[id] }));
|
||||
|
||||
/** Health badge severities in the order the health column sorts them. */
|
||||
const HEALTH_SORT_PRIORITY: Record<HealthSeverity, number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
healthy: 2,
|
||||
unknown: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a sorting function factory that pushes unverified domains to the end.
|
||||
* Returns a function that creates `sortFn` functions with access to the current
|
||||
@@ -273,13 +282,8 @@ export function createColumns(
|
||||
// Within the same status, sort by expiration date for more granular ordering
|
||||
sortFn: withUnverifiedLast((a, b) => {
|
||||
const now = new Date();
|
||||
const getHealthPriority = (exp: Date | null, verified: boolean): number => {
|
||||
if (!verified || !exp) return 3; // unknown
|
||||
const days = Math.floor((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (days <= 7) return 0; // critical
|
||||
if (days <= 30) return 1; // warning
|
||||
return 2; // healthy
|
||||
};
|
||||
const getHealthPriority = (exp: Date | null, verified: boolean): number =>
|
||||
HEALTH_SORT_PRIORITY[getHealthSeverity(exp, verified, now)];
|
||||
|
||||
const aPriority = getHealthPriority(a.expirationDate, a.verified);
|
||||
const bPriority = getHealthPriority(b.expirationDate, b.verified);
|
||||
|
||||
@@ -5,16 +5,15 @@ import {
|
||||
IconQuestionMark,
|
||||
type TablerIcon,
|
||||
} from "@tabler/icons-react";
|
||||
import { differenceInDays, formatDistanceStrict } from "date-fns";
|
||||
import { formatDistanceStrict } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
|
||||
import { useHydratedNow } from "@/hooks/use-hydrated-now";
|
||||
import { getHealthSeverity, type HealthSeverity } from "@/lib/dashboard-utils";
|
||||
import { cn } from "@domainstack/ui/utils";
|
||||
import { toDateTimeAttr } from "@domainstack/utils/date";
|
||||
|
||||
type HealthStatus = "healthy" | "warning" | "critical" | "unknown";
|
||||
|
||||
type DomainHealthBadgeProps = {
|
||||
expirationDate: Date | null;
|
||||
verified: boolean;
|
||||
@@ -26,7 +25,9 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
|
||||
const dateTime = expirationDate ? toDateTimeAttr(expirationDate) : undefined;
|
||||
|
||||
// SSR and invalid dates stay "unknown" — NaN day counts would otherwise look healthy.
|
||||
const status = now ? getHealthStatus(dateTime ? expirationDate : null, verified, now) : "unknown";
|
||||
const status = now
|
||||
? getHealthSeverity(dateTime ? expirationDate : null, verified, now)
|
||||
: "unknown";
|
||||
const { label, colorClass, icon } = getStatusConfig(status);
|
||||
|
||||
const tooltipText = useMemo(() => {
|
||||
@@ -55,23 +56,7 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
|
||||
);
|
||||
}
|
||||
|
||||
function getHealthStatus(expirationDate: Date | null, verified: boolean, now: Date): HealthStatus {
|
||||
if (!verified || !expirationDate || Number.isNaN(expirationDate.getTime())) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const daysUntilExpiry = differenceInDays(expirationDate, now);
|
||||
|
||||
if (daysUntilExpiry <= 7) {
|
||||
return "critical";
|
||||
}
|
||||
if (daysUntilExpiry <= 30) {
|
||||
return "warning";
|
||||
}
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
function getStatusConfig(status: HealthStatus): {
|
||||
function getStatusConfig(status: HealthSeverity): {
|
||||
label: string;
|
||||
colorClass: string;
|
||||
icon: TablerIcon;
|
||||
@@ -117,7 +102,7 @@ export function getHealthAccent(
|
||||
// If no 'now' provided, return slate (unknown) to avoid Date.now() during SSR
|
||||
if (!now) return "slate";
|
||||
|
||||
const status = getHealthStatus(expirationDate, verified, now);
|
||||
const status = getHealthSeverity(expirationDate, verified, now);
|
||||
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
|
||||
@@ -92,8 +92,7 @@ export function SectionNav({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 overflow-hidden",
|
||||
"transition-all duration-200 ease-out",
|
||||
"flex shrink-0 items-center gap-2 overflow-hidden transition duration-200 ease-out",
|
||||
isHeaderVisible
|
||||
? "w-0 opacity-0"
|
||||
: "mr-3 w-auto border-r border-black/10 pr-3 opacity-100 dark:border-white/10",
|
||||
@@ -129,7 +128,7 @@ export function SectionNav({
|
||||
}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-[13px] tracking-[0.01em] whitespace-nowrap",
|
||||
"transition-all duration-150",
|
||||
"transition duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"hover:!bg-[color-mix(in_oklch,var(--section-accent)_15%,transparent)] hover:text-foreground",
|
||||
activeSection === slug
|
||||
|
||||
@@ -10,6 +10,36 @@ import { useSyncExternalStore } from "react";
|
||||
let hydratedNow: Date | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let cancelPendingInitializer: (() => void) | null = null;
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** Set by `resetHydratedNow` so a pinned test clock never drifts. */
|
||||
let pinned = false;
|
||||
|
||||
/**
|
||||
* How often the shared clock advances. Everything built on it is phrased in
|
||||
* minutes or days ("expires in 6 days"), so a minute is fine and keeps the
|
||||
* re-render cascade rare on a tab left open for hours.
|
||||
*/
|
||||
const TICK_MS = 60_000;
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function startTicking(): void {
|
||||
if (pinned || tickTimer !== null || typeof window === "undefined") return;
|
||||
tickTimer = setInterval(() => {
|
||||
hydratedNow = new Date();
|
||||
emit();
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
function stopTicking(): void {
|
||||
if (tickTimer === null) return;
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
@@ -25,18 +55,25 @@ function subscribe(callback: () => void): () => void {
|
||||
return;
|
||||
}
|
||||
hydratedNow = new Date();
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
emit();
|
||||
startTicking();
|
||||
});
|
||||
cancelPendingInitializer = () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
} else if (hydratedNow !== null) {
|
||||
// Clock already running from an earlier mount; keep it going.
|
||||
startTicking();
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(callback);
|
||||
// Nothing on screen depends on the time any more, so stop re-rendering
|
||||
// for it. The next subscriber restarts the interval.
|
||||
if (listeners.size === 0) {
|
||||
stopTicking();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,8 +104,9 @@ export function useHydratedNow(): Date | null {
|
||||
export function resetHydratedNow(date: Date | null = new Date()): void {
|
||||
cancelPendingInitializer?.();
|
||||
cancelPendingInitializer = null;
|
||||
// Pinning the clock means pinning it: no interval may advance it afterwards.
|
||||
stopTicking();
|
||||
pinned = date !== null;
|
||||
hydratedNow = date ? new Date(date.getTime()) : null;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
|
||||
import { EXPIRING_SOON_DAYS } from "@domainstack/constants";
|
||||
import { EXPIRING_CRITICAL_DAYS, EXPIRING_SOON_DAYS } from "@domainstack/constants";
|
||||
import type { TrackedDomainWithDetails } from "@domainstack/types";
|
||||
import { calculateDaysRemaining } from "@domainstack/utils/expiry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Types
|
||||
@@ -30,6 +31,29 @@ const VALID_STATUS_FILTERS = new Set<StatusFilter>(["verified", "pending"]);
|
||||
/** Valid health filter values for runtime validation of URL params */
|
||||
const VALID_HEALTH_FILTERS = new Set<HealthFilter>(["healthy", "expiring", "expired"]);
|
||||
|
||||
/** Severity shown on a domain's health badge. */
|
||||
export type HealthSeverity = "healthy" | "warning" | "critical" | "unknown";
|
||||
|
||||
/**
|
||||
* Whole days until a tracked domain expires, or null when there is nothing to
|
||||
* count (unverified, missing date, unparseable date).
|
||||
*
|
||||
* Every dashboard reading of "days left" goes through here so the filter, the
|
||||
* summary counts, the row badge, and the sort all classify a domain the same
|
||||
* way. `calculateDaysRemaining` is the same helper the expiry notifications
|
||||
* use, so the dashboard and the emails agree on the day count too.
|
||||
*/
|
||||
export function getDaysUntilExpiry(
|
||||
expirationDate: Date | null,
|
||||
verified: boolean,
|
||||
now: Date,
|
||||
): number | null {
|
||||
if (!verified || !expirationDate || Number.isNaN(expirationDate.getTime())) return null;
|
||||
|
||||
const days = calculateDaysRemaining(expirationDate, now);
|
||||
return Number.isNaN(days) ? null : days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine health status based on expiration date
|
||||
*/
|
||||
@@ -38,17 +62,33 @@ function getHealthStatus(
|
||||
verified: boolean,
|
||||
now: Date,
|
||||
): HealthFilter | null {
|
||||
if (!verified || !expirationDate || Number.isNaN(expirationDate.getTime())) return null;
|
||||
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
const daysUntilExpiry = getDaysUntilExpiry(expirationDate, verified, now);
|
||||
if (daysUntilExpiry === null) return null;
|
||||
|
||||
if (daysUntilExpiry <= 0) return "expired";
|
||||
if (daysUntilExpiry <= EXPIRING_SOON_DAYS) return "expiring";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine badge severity based on expiration date.
|
||||
*
|
||||
* Shares its day count and thresholds with the health filter above, so a row
|
||||
* badge can never contradict the "Expiring Soon" summary beside it.
|
||||
*/
|
||||
export function getHealthSeverity(
|
||||
expirationDate: Date | null,
|
||||
verified: boolean,
|
||||
now: Date,
|
||||
): HealthSeverity {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(expirationDate, verified, now);
|
||||
if (daysUntilExpiry === null) return "unknown";
|
||||
|
||||
if (daysUntilExpiry <= EXPIRING_CRITICAL_DAYS) return "critical";
|
||||
if (daysUntilExpiry <= EXPIRING_SOON_DAYS) return "warning";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
export const DASHBOARD_VIEW_MODE_OPTIONS = ["grid", "table"] as const;
|
||||
export type DashboardViewModeOptions = (typeof DASHBOARD_VIEW_MODE_OPTIONS)[number];
|
||||
|
||||
|
||||
@@ -85,14 +85,13 @@ async function clearEndsAt(userId: string): Promise<void> {
|
||||
async function downgrade(userId: string): Promise<number> {
|
||||
"use step";
|
||||
|
||||
const [{ clearSubscriptionEndsAt }, { handleDowngrade }, { sendSubscriptionExpiredEmail }] =
|
||||
const [{ clearSubscriptionEndsAt, downgradeToFree }, { sendSubscriptionExpiredEmail }] =
|
||||
await Promise.all([
|
||||
import("@domainstack/db/queries/user-subscription"),
|
||||
import("@domainstack/polar/downgrade"),
|
||||
import("@domainstack/polar/emails"),
|
||||
]);
|
||||
|
||||
const archivedCount = await handleDowngrade(userId);
|
||||
const archivedCount = await downgradeToFree(userId);
|
||||
await clearSubscriptionEndsAt(userId);
|
||||
|
||||
try {
|
||||
|
||||
@@ -34,6 +34,10 @@ type NotificationType =
|
||||
// Dashboard "expiring soon" threshold (first notification threshold)
|
||||
export const EXPIRING_SOON_DAYS = DOMAIN_EXPIRY_THRESHOLDS[0];
|
||||
|
||||
// Dashboard "critical" threshold, where an expiry badge turns red. Matches the
|
||||
// `domain_expiry_7d` notification so the badge and the email agree.
|
||||
export const EXPIRING_CRITICAL_DAYS = 7 satisfies DomainExpiryThreshold;
|
||||
|
||||
// Mapping from threshold to notification type
|
||||
export const DOMAIN_THRESHOLD_TO_TYPE: Record<DomainExpiryThreshold, NotificationType> = {
|
||||
30: "domain_expiry_30d",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"./products": "./src/products.ts",
|
||||
"./handlers": "./src/handlers.ts",
|
||||
"./emails": "./src/emails.ts",
|
||||
"./downgrade": "./src/downgrade.ts",
|
||||
"./reconcile": "./src/reconcile.ts",
|
||||
"./better-auth/server": "./src/better-auth/server.ts",
|
||||
"./better-auth/client": "./src/better-auth/client.ts",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { downgradeToFree } from "@domainstack/db/queries/user-subscription";
|
||||
|
||||
/**
|
||||
* Handle user downgrade from Pro to Free tier.
|
||||
* Archives oldest domains that exceed the free tier limit.
|
||||
*
|
||||
* @param userId - The user ID (Polar customer ID maps to our user ID)
|
||||
* @returns The number of domains that were archived (0 if none)
|
||||
*/
|
||||
export async function handleDowngrade(userId: string): Promise<number> {
|
||||
return downgradeToFree(userId);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const {
|
||||
updateUserTier,
|
||||
setSubscriptionEndsAt,
|
||||
clearSubscriptionEndsAt,
|
||||
downgradeToFree,
|
||||
getUserSubscription,
|
||||
getCustomerSubscriptionState,
|
||||
createMockLogger,
|
||||
@@ -60,6 +61,7 @@ const {
|
||||
updateUserTier: vi.fn<(userId: string, tier: "free" | "pro") => Promise<void>>(),
|
||||
setSubscriptionEndsAt: vi.fn<(userId: string, endsAt: Date) => Promise<void>>(),
|
||||
clearSubscriptionEndsAt: vi.fn<(userId: string) => Promise<void>>(),
|
||||
downgradeToFree: vi.fn<(userId: string) => Promise<number>>(),
|
||||
getUserSubscription: vi.fn<(userId: string) => Promise<UserSubscriptionFixture>>(),
|
||||
getCustomerSubscriptionState: vi.fn<(userId: string) => Promise<CustomerStateFixture>>(),
|
||||
createMockLogger: buildMockLogger,
|
||||
@@ -73,6 +75,7 @@ vi.mock("@domainstack/db/queries/user-subscription", () => ({
|
||||
updateUserTier,
|
||||
setSubscriptionEndsAt,
|
||||
clearSubscriptionEndsAt,
|
||||
downgradeToFree,
|
||||
getUserSubscription,
|
||||
}));
|
||||
|
||||
@@ -83,10 +86,6 @@ vi.mock("@domainstack/logger", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./downgrade", () => ({
|
||||
handleDowngrade: vi.fn<(userId: string) => Promise<number>>(),
|
||||
}));
|
||||
|
||||
vi.mock("./products", () => ({
|
||||
getTierForProductId: vi.fn<(productId: string) => "pro" | null>(),
|
||||
getProductByProductId:
|
||||
@@ -109,7 +108,6 @@ vi.mock("./reconcile", () => ({
|
||||
getCustomerSubscriptionState,
|
||||
}));
|
||||
|
||||
import { handleDowngrade } from "./downgrade";
|
||||
import {
|
||||
sendProUpgradeEmail,
|
||||
sendSubscriptionCancelingEmail,
|
||||
@@ -354,7 +352,7 @@ describe("handleSubscriptionCanceled", () => {
|
||||
expect(setSubscriptionEndsAt).toHaveBeenCalledWith("user-456", periodEnd);
|
||||
// Should NOT change tier yet
|
||||
expect(updateUserTier).not.toHaveBeenCalled();
|
||||
expect(handleDowngrade).not.toHaveBeenCalled();
|
||||
expect(downgradeToFree).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not set end date when currentPeriodEnd is null", async () => {
|
||||
@@ -452,15 +450,15 @@ describe("handleSubscriptionCanceled", () => {
|
||||
describe("handleSubscriptionRevoked", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default: handleDowngrade returns 0 archived domains
|
||||
vi.mocked(handleDowngrade).mockResolvedValue(0);
|
||||
// Default: downgradeToFree returns 0 archived domains
|
||||
vi.mocked(downgradeToFree).mockResolvedValue(0);
|
||||
vi.mocked(getCustomerSubscriptionState).mockResolvedValue(okPolarState());
|
||||
});
|
||||
|
||||
it("calls handleDowngrade with user ID from customer.externalId", async () => {
|
||||
it("calls downgradeToFree with user ID from customer.externalId", async () => {
|
||||
await handleSubscriptionRevoked(createRevokedPayload());
|
||||
|
||||
expect(handleDowngrade).toHaveBeenCalledWith("user-456");
|
||||
expect(downgradeToFree).toHaveBeenCalledWith("user-456");
|
||||
});
|
||||
|
||||
it("clears subscription end date after downgrade", async () => {
|
||||
@@ -470,7 +468,7 @@ describe("handleSubscriptionRevoked", () => {
|
||||
});
|
||||
|
||||
it("sends subscription expired email with archived count", async () => {
|
||||
vi.mocked(handleDowngrade).mockResolvedValue(3);
|
||||
vi.mocked(downgradeToFree).mockResolvedValue(3);
|
||||
|
||||
await handleSubscriptionRevoked(createRevokedPayload());
|
||||
|
||||
@@ -483,14 +481,14 @@ describe("handleSubscriptionRevoked", () => {
|
||||
// Should not throw - email errors are logged but swallowed
|
||||
await expect(handleSubscriptionRevoked(createRevokedPayload())).resolves.not.toThrow();
|
||||
|
||||
expect(handleDowngrade).toHaveBeenCalled();
|
||||
expect(downgradeToFree).toHaveBeenCalled();
|
||||
expect(clearSubscriptionEndsAt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not downgrade when externalId (userId) is missing", async () => {
|
||||
await handleSubscriptionRevoked(createRevokedPayload({ userId: null }));
|
||||
|
||||
expect(handleDowngrade).not.toHaveBeenCalled();
|
||||
expect(downgradeToFree).not.toHaveBeenCalled();
|
||||
expect(clearSubscriptionEndsAt).not.toHaveBeenCalled();
|
||||
expect(sendSubscriptionExpiredEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -500,7 +498,7 @@ describe("handleSubscriptionRevoked", () => {
|
||||
|
||||
await handleSubscriptionRevoked(createRevokedPayload());
|
||||
|
||||
expect(handleDowngrade).not.toHaveBeenCalled();
|
||||
expect(downgradeToFree).not.toHaveBeenCalled();
|
||||
expect(clearSubscriptionEndsAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -511,12 +509,12 @@ describe("handleSubscriptionRevoked", () => {
|
||||
|
||||
await handleSubscriptionRevoked(createRevokedPayload());
|
||||
|
||||
expect(handleDowngrade).not.toHaveBeenCalled();
|
||||
expect(downgradeToFree).not.toHaveBeenCalled();
|
||||
expect(clearSubscriptionEndsAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-throws errors from handleDowngrade for webhook retry", async () => {
|
||||
vi.mocked(handleDowngrade).mockRejectedValue(new Error("Downgrade failed"));
|
||||
it("re-throws errors from downgradeToFree for webhook retry", async () => {
|
||||
vi.mocked(downgradeToFree).mockRejectedValue(new Error("Downgrade failed"));
|
||||
|
||||
await expect(handleSubscriptionRevoked(createRevokedPayload())).rejects.toThrow(
|
||||
"Downgrade failed",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WebhooksOptions } from "@polar-sh/better-auth";
|
||||
|
||||
import {
|
||||
clearSubscriptionEndsAt,
|
||||
downgradeToFree,
|
||||
getUserSubscription,
|
||||
setSubscriptionEndsAt,
|
||||
updateUserTier,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
import { createLogger } from "@domainstack/logger";
|
||||
|
||||
import { analytics } from "./analytics";
|
||||
import { handleDowngrade } from "./downgrade";
|
||||
import {
|
||||
sendProUpgradeEmail,
|
||||
sendSubscriptionCancelingEmail,
|
||||
@@ -243,7 +243,7 @@ export async function handleSubscriptionRevoked(
|
||||
}
|
||||
|
||||
// Downgrade user to free tier (may archive domains if over limit)
|
||||
const archivedCount = await handleDowngrade(userId);
|
||||
const archivedCount = await downgradeToFree(userId);
|
||||
|
||||
// Clear the subscription end date
|
||||
await clearSubscriptionEndsAt(userId);
|
||||
|
||||
@@ -6,8 +6,7 @@ const { makePGliteDb, closePGliteDb } = await import("@domainstack/db/testing");
|
||||
const { db } = await makePGliteDb();
|
||||
|
||||
// Now import modules that depend on the db (they'll use the test db via lazy init)
|
||||
const { handleDowngrade } = await import("./downgrade");
|
||||
const { getUserIdsPastDue, getUserSubscription } =
|
||||
const { downgradeToFree, getUserIdsPastDue, getUserSubscription } =
|
||||
await import("@domainstack/db/queries/user-subscription");
|
||||
const { domains, userSubscriptions, users, userTrackedDomains } =
|
||||
await import("@domainstack/db/schema");
|
||||
@@ -60,7 +59,7 @@ beforeEach(async () => {
|
||||
await db.update(userSubscriptions).set({ tier: "pro" });
|
||||
});
|
||||
|
||||
describe("handleDowngrade", () => {
|
||||
describe("downgradeToFree", () => {
|
||||
it("updates user tier to free", async () => {
|
||||
// Add 3 tracked domains (under limit)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
@@ -72,7 +71,7 @@ describe("handleDowngrade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
await handleDowngrade(testUserId);
|
||||
await downgradeToFree(testUserId);
|
||||
|
||||
// Check tier was updated to free
|
||||
const [subscription] = await db.select().from(userSubscriptions).limit(1);
|
||||
@@ -90,7 +89,7 @@ describe("handleDowngrade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await handleDowngrade(testUserId);
|
||||
const result = await downgradeToFree(testUserId);
|
||||
|
||||
expect(result).toBe(0);
|
||||
|
||||
@@ -110,7 +109,7 @@ describe("handleDowngrade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await handleDowngrade(testUserId);
|
||||
const result = await downgradeToFree(testUserId);
|
||||
|
||||
expect(result).toBe(0);
|
||||
|
||||
@@ -130,7 +129,7 @@ describe("handleDowngrade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await handleDowngrade(testUserId);
|
||||
const result = await downgradeToFree(testUserId);
|
||||
|
||||
expect(result).toBe(3);
|
||||
|
||||
@@ -156,7 +155,7 @@ describe("handleDowngrade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await handleDowngrade(testUserId);
|
||||
const result = await downgradeToFree(testUserId);
|
||||
|
||||
expect(result).toBe(2);
|
||||
|
||||
@@ -195,7 +194,7 @@ describe("handleDowngrade", () => {
|
||||
verified: true,
|
||||
});
|
||||
|
||||
const result = await handleDowngrade(newUser.id);
|
||||
const result = await downgradeToFree(newUser.id);
|
||||
|
||||
expect(result).toBe(0);
|
||||
|
||||
+20
-21
@@ -19,35 +19,34 @@ let redis: Redis | undefined;
|
||||
* @returns Redis client instance, or undefined if not configured
|
||||
*/
|
||||
export function getRedis(): Redis | undefined {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
(!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN)
|
||||
) {
|
||||
logger.warn("UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are not set");
|
||||
// Don't block app if Redis is not set in development
|
||||
// Mirror the fallbacks in `Redis.fromEnv`, which also accepts the
|
||||
// `KV_REST_API_*` pair used by Vercel KV. Checking only the `UPSTASH_*`
|
||||
// names would report "not configured" on a perfectly good Vercel KV setup.
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
|
||||
|
||||
if (!url || !token) {
|
||||
// `Redis.fromEnv` only warns on a missing pair and hands back a client
|
||||
// whose every call fails. Callers all branch on `undefined` to fail open,
|
||||
// so return that instead of a client that cannot work.
|
||||
const message =
|
||||
"Redis is not configured (set UPSTASH_REDIS_REST_URL/TOKEN or KV_REST_API_URL/TOKEN); continuing without it";
|
||||
// Expected locally, but in production it silently drops rate limiting and
|
||||
// session caching, so it should page rather than blend into the logs.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
logger.error(message);
|
||||
} else {
|
||||
logger.warn(message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!redis) {
|
||||
redis = Redis.fromEnv();
|
||||
redis = new Redis({ url, token });
|
||||
}
|
||||
|
||||
return redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redis client from explicit configuration.
|
||||
* Use this when environment variables are not available.
|
||||
*
|
||||
* @param config - Redis connection configuration
|
||||
* @returns Redis client instance
|
||||
*/
|
||||
export function createRedisClient(config: { url: string; token: string }): Redis {
|
||||
return new Redis({
|
||||
url: config.url,
|
||||
token: config.token,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export the Redis type for consumers
|
||||
export { Redis } from "@upstash/redis";
|
||||
|
||||
@@ -64,7 +64,6 @@ function PaginationLink({
|
||||
nativeButton={false}
|
||||
render={
|
||||
// Children are passed through `...props` by PaginationLink callers.
|
||||
// oxlint-disable-next-line jsx-a11y/anchor-has-content
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
|
||||
Reference in New Issue
Block a user