Merge branch 'dev' into setup-page-redo

This commit is contained in:
Armaan Jain 2026-06-24 12:20:36 -07:00 committed by GitHub
commit 78a3d220e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 655 additions and 996 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/backend",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "StripeWebhookEventStatus" AS ENUM ('PENDING', 'PROCESSED', 'FAILED');
-- CreateTable
CREATE TABLE "StripeWebhookEvent" (
"id" UUID NOT NULL,
"stripeEventId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"stripeAccountId" TEXT,
"payload" JSONB NOT NULL,
"status" "StripeWebhookEventStatus" NOT NULL DEFAULT 'PENDING',
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"processedAt" TIMESTAMP(3),
CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "StripeWebhookEvent_stripeEventId_key" ON "StripeWebhookEvent"("stripeEventId");

View File

@ -0,0 +1,60 @@
import { randomUUID } from "crypto";
import type { Sql } from "postgres";
import { expect } from "vitest";
export const preMigration = async (sql: Sql) => {
// Table does not exist before the migration, so nothing to seed.
return {};
};
export const postMigration = async (sql: Sql) => {
const tables = await sql<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'StripeWebhookEvent'
`;
expect(Array.from(tables)).toMatchInlineSnapshot(`
[
{
"table_name": "StripeWebhookEvent",
},
]
`);
const eventId = `evt_${randomUUID()}`;
await sql`
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "updatedAt")
VALUES (${randomUUID()}::uuid, ${eventId}, 'invoice.payment_succeeded', '{"id":"evt"}'::jsonb, NOW())
`;
// Status defaults to PENDING.
const inserted = await sql`
SELECT "status" FROM "StripeWebhookEvent" WHERE "stripeEventId" = ${eventId}
`;
expect(Array.from(inserted)).toMatchInlineSnapshot(`
[
{
"status": "PENDING",
},
]
`);
// The same Stripe event id cannot be inserted twice (idempotency guarantee).
await expect(sql`
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "updatedAt")
VALUES (${randomUUID()}::uuid, ${eventId}, 'invoice.payment_succeeded', '{"id":"evt2"}'::jsonb, NOW())
`).rejects.toThrow(/StripeWebhookEvent_stripeEventId_key/);
// A different event id is fine, and the status enum rejects invalid values.
await sql`
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "status", "updatedAt")
VALUES (${randomUUID()}::uuid, ${`evt_${randomUUID()}`}, 'invoice.paid', '{}'::jsonb, 'PROCESSED', NOW())
`;
await expect(sql`
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "status", "updatedAt")
VALUES (${randomUUID()}::uuid, ${`evt_${randomUUID()}`}, 'invoice.paid', '{}'::jsonb, 'NOT_A_STATUS', NOW())
`).rejects.toThrow();
};

View File

@ -1427,6 +1427,32 @@ model OutgoingRequest {
@@index([startedFulfillingAt, deduplicationKey])
}
enum StripeWebhookEventStatus {
PENDING
PROCESSED
FAILED
}
// Idempotency + recovery log for incoming Stripe webhook events. Each event is
// persisted synchronously (keyed on the Stripe `event.id`) before we ack 200 to
// Stripe, so redeliveries are deduped and the full `payload` of any
// PENDING/FAILED row can be replayed manually. Not tenancy-scoped: the Stripe
// account -> tenancy resolution happens during processing, not here.
model StripeWebhookEvent {
id String @id @default(uuid()) @db.Uuid
stripeEventId String @unique
eventType String
stripeAccountId String?
payload Json
status StripeWebhookEventStatus @default(PENDING)
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
processedAt DateTime?
}
// BulldozerStorageEngine is managed externally (see prisma.config.ts
// `tables.external`). It's created by migrations and interacted with
// via raw SQL — not through the Prisma client. Keeping it out of the

View File

@ -1,5 +1,7 @@
import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails";
import { bulldozerWriteOneTimePurchase } from "@/lib/payments/bulldozer-dual-write";
import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "@/lib/stripe-webhook-events";
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import { listPermissions } from "@/lib/permissions";
import { getHexclaveStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
@ -466,13 +468,29 @@ export const POST = createSmartRouteHandler({
throw new StatusError(400, "Invalid stripe-signature header");
}
try {
await processStripeWebhookEvent(event);
} catch (error) {
captureError("stripe-webhook-receiver", error);
throw error;
// Persist the event for idempotency + recovery BEFORE acking. Stripe
// delivers at-least-once
const { shouldProcess } = await claimStripeEvent(event);
if (!shouldProcess) {
return {
statusCode: 200,
bodyType: "json",
body: { received: true, deduplicated: true },
};
}
// Ack Stripe immediately and process in the background.
// Stripe recommends ACKing ASAP to avoid timeouts and redeliveries
runAsynchronouslyAndWaitUntil(async () => {
try {
await processStripeWebhookEvent(event);
await markStripeEventProcessed(event.id);
} catch (error) {
captureError("stripe-webhook-receiver", error);
await markStripeEventFailed(event.id, error);
}
});
return {
statusCode: 200,
bodyType: "json",

View File

@ -0,0 +1,122 @@
import { randomUUID } from "node:crypto";
import type Stripe from "stripe";
import { describe, expect, it } from "vitest";
import { StripeWebhookEventStatus } from "@/generated/prisma/client";
import { globalPrismaClient } from "@/prisma-client";
import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "./stripe-webhook-events";
// Test fixtures only need the fields the helper reads (id/type/account) plus a
// JSON-serializable body. Building a full Stripe.Event is impractical, so we
// cast a minimal object — any drift in the fields we actually use is still
// caught because the helper reads them directly.
function makeEvent(): Stripe.Event {
return {
id: `evt_${randomUUID()}`,
type: "invoice.payment_succeeded",
account: "acct_test_123",
data: { object: { id: "in_test", note: "fixture" } },
} as unknown as Stripe.Event;
}
describe("stripe webhook event idempotency (real DB)", () => {
it("claims a brand new event and persists it as PENDING", async ({ expect }) => {
const event = makeEvent();
const { shouldProcess } = await claimStripeEvent(event);
expect(shouldProcess).toBe(true);
const row = await globalPrismaClient.stripeWebhookEvent.findUnique({
where: { stripeEventId: event.id },
});
expect(row).not.toBeNull();
expect(row?.status).toBe(StripeWebhookEventStatus.PENDING);
expect(row?.eventType).toBe(event.type);
expect(row?.stripeAccountId).toBe(event.account);
expect(row?.processedAt).toBeNull();
expect(row?.lastError).toBeNull();
// The full event payload is stored so dropped/failed events can be replayed.
expect(row?.payload).toMatchObject({ id: event.id, type: event.type });
});
it("skips a redelivery while the prior delivery is still in-flight (PENDING)", async ({ expect }) => {
const event = makeEvent();
const first = await claimStripeEvent(event);
expect(first.shouldProcess).toBe(true);
// Single-flight: a redelivery that arrives while the first attempt is still
// PENDING must not spin up a second processor (that would double the fan-out).
const second = await claimStripeEvent(event);
expect(second.shouldProcess).toBe(false);
});
it("deduplicates once the event has been fully PROCESSED", async ({ expect }) => {
const event = makeEvent();
await claimStripeEvent(event);
await markStripeEventProcessed(event.id);
const processedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
where: { stripeEventId: event.id },
});
expect(processedRow?.status).toBe(StripeWebhookEventStatus.PROCESSED);
expect(processedRow?.processedAt).not.toBeNull();
expect(processedRow?.lastError).toBeNull();
// A Stripe redelivery of an already-processed event must be a no-op.
const redelivery = await claimStripeEvent(event);
expect(redelivery.shouldProcess).toBe(false);
});
it("records the error on failure and allows recovery via redelivery", async ({ expect }) => {
const event = makeEvent();
await claimStripeEvent(event);
await markStripeEventFailed(event.id, new Error("boom while processing"));
const failedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
where: { stripeEventId: event.id },
});
expect(failedRow?.status).toBe(StripeWebhookEventStatus.FAILED);
expect(failedRow?.lastError).toContain("boom while processing");
// FAILED rows must reprocess so a manual Stripe "Resend" can recover them.
const recovery = await claimStripeEvent(event);
expect(recovery.shouldProcess).toBe(true);
// ...but reclaiming a FAILED row flips it back to in-flight (PENDING), so a
// further redelivery during that retry is once again skipped (single-flight).
const concurrentRetry = await claimStripeEvent(event);
expect(concurrentRetry.shouldProcess).toBe(false);
});
it("scrubs a stale processedAt when a row leaves the PROCESSED state", async ({ expect }) => {
const event = makeEvent();
await claimStripeEvent(event);
await markStripeEventProcessed(event.id);
// markStripeEventFailed must clear processedAt so a recovered/re-failed row is
// never readable as "completed at <time>".
await markStripeEventFailed(event.id, new Error("late failure after success"));
const failedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
where: { stripeEventId: event.id },
});
expect(failedRow?.status).toBe(StripeWebhookEventStatus.FAILED);
expect(failedRow?.processedAt).toBeNull();
// Force a stale processedAt on a FAILED row, then prove the FAILED -> PENDING
// recovery transition scrubs it too.
await globalPrismaClient.stripeWebhookEvent.update({
where: { stripeEventId: event.id },
data: { processedAt: new Date() },
});
const recovery = await claimStripeEvent(event);
expect(recovery.shouldProcess).toBe(true);
const recoveredRow = await globalPrismaClient.stripeWebhookEvent.findUnique({
where: { stripeEventId: event.id },
});
expect(recoveredRow?.status).toBe(StripeWebhookEventStatus.PENDING);
expect(recoveredRow?.processedAt).toBeNull();
});
});

View File

@ -0,0 +1,60 @@
import { randomUUID } from "node:crypto";
import { StripeWebhookEventStatus } from "@/generated/prisma/client";
import { globalPrismaClient } from "@/prisma-client";
import { errorToNiceString } from "@hexclave/shared/dist/utils/errors";
import type Stripe from "stripe";
/**
* Idempotency + recovery layer for incoming Stripe webhook events.
*
* Each event is persisted (keyed on the Stripe `event.id`) synchronously before
* we ack 200 to Stripe. Processing then runs in the background. Because Stripe
* delivers at-least-once, this is what guarantees the receipt fan-out happens at
* most once per event. The full `payload` is stored so PENDING/FAILED rows can
* be replayed manually if the background work is dropped (e.g. instance recycle).
*/
/**
* Atomically claims an event for processing, guaranteeing single-flight: at most
* one worker processes a given event at a time. Returns `shouldProcess: true`
* only to the caller that won the claim.
*
* A new event is inserted as PENDING. On a redelivery, the claim is only handed
* out again if the previous attempt FAILED (the `WHERE status = 'FAILED'` makes
* the takeover conditional and atomic, so concurrent redeliveries can't both
* win). PENDING (in-flight) and PROCESSED (done) rows yield no row and are
* skipped. A PENDING row whose worker died is recovered manually from `payload`.
*/
export async function claimStripeEvent(event: Stripe.Event): Promise<{ shouldProcess: boolean }> {
const claimed = await globalPrismaClient.$queryRaw<{ id: string }[]>`
INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "stripeAccountId", "payload", "status", "updatedAt")
VALUES (${randomUUID()}::uuid, ${event.id}, ${event.type}, ${event.account ?? null}, ${JSON.stringify(event)}::jsonb, 'PENDING', now())
ON CONFLICT ("stripeEventId") DO UPDATE
SET "status" = 'PENDING', "lastError" = NULL, "processedAt" = NULL, "updatedAt" = now()
WHERE "StripeWebhookEvent"."status" = 'FAILED'
RETURNING "id"
`;
return { shouldProcess: claimed.length === 1 };
}
export async function markStripeEventProcessed(stripeEventId: string): Promise<void> {
await globalPrismaClient.stripeWebhookEvent.update({
where: { stripeEventId },
data: {
status: StripeWebhookEventStatus.PROCESSED,
processedAt: new Date(),
lastError: null,
},
});
}
export async function markStripeEventFailed(stripeEventId: string, error: unknown): Promise<void> {
await globalPrismaClient.stripeWebhookEvent.update({
where: { stripeEventId },
data: {
status: StripeWebhookEventStatus.FAILED,
lastError: errorToNiceString(error),
processedAt: null,
},
});
}

View File

@ -742,7 +742,6 @@ Reference surfaces:
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]`
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails`
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts`
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox`
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates`
- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes`
@ -803,21 +802,7 @@ Keep:
- specialized editor layout systems if no design-components equivalent exists
### 5.4 `/projects/[projectId]/email-outbox`
Use:
- section cards: `DesignCard` (preferred for visual consistency with other email screens)
- filters: `DesignSelectorDropdown`, `DesignInput`
- status badges: `DesignBadge`
- action buttons/menus: `DesignButton`, `DesignMenu`
- data grid/list table: `DataGrid` + `useDataSource` + `createDefaultDataGridState`
Avoid:
- mixed badge systems (`Badge` in some places, custom badges elsewhere)
### 5.5 `/projects/[projectId]/email-templates`
### 5.4 `/projects/[projectId]/email-templates`
Use:
@ -830,7 +815,7 @@ Avoid:
- inline repeated glass class blocks for each template card
### 5.6 `/projects/[projectId]/email-templates/[templateId]`
### 5.5 `/projects/[projectId]/email-templates/[templateId]`
Use:
@ -838,7 +823,7 @@ Use:
- top actions: `DesignButton`
- state tags: `DesignBadge` where needed
### 5.7 `/projects/[projectId]/email-themes`
### 5.6 `/projects/[projectId]/email-themes`
Use:
@ -852,7 +837,7 @@ Avoid:
- custom `ViewportSelector` if `DesignPillToggle` supports the same behavior
### 5.8 `/projects/[projectId]/email-themes/[themeId]`
### 5.7 `/projects/[projectId]/email-themes/[themeId]`
Use:

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/dashboard",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -15,6 +15,7 @@ import {
WarningCircleIcon
} from "@phosphor-icons/react";
import { Alert, AlertDescription, Button } from "@/components/ui";
import { Link } from "@/components/link";
import { useDashboardInternalUser } from "@/lib/dashboard-user";
import { PLAN_LIMITS, resolvePlanId } from "@hexclave/shared/dist/plans";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
@ -340,7 +341,7 @@ export function AnalyticsEventLimitBanner() {
return null;
}
return <AnalyticsEventLimitBannerInner team={ownerTeam} />;
return <AnalyticsEventLimitBannerInner team={ownerTeam} projectId={project.id} />;
}
/**
@ -395,7 +396,7 @@ function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: str
);
}
function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise<string> } }) {
function AnalyticsEventLimitBannerInner({ team, projectId }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise<string> }, projectId: string }) {
const eventsItem = team.useItem("analytics_events");
const products = team.useProducts();
const planId = resolvePlanId(products);
@ -433,15 +434,26 @@ function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: st
}
{canUpgrade && !isExhausted && " Consider upgrading your plan."}
</span>
{canUpgrade && (
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant={isExhausted ? "destructive" : "outline"}
onClick={handleUpgrade}
variant="outline"
asChild
>
Upgrade plan
<Link href={`/projects/${projectId}/project-settings/usage`}>
View usage
</Link>
</Button>
)}
{canUpgrade && (
<Button
size="sm"
variant={isExhausted ? "destructive" : "outline"}
onClick={handleUpgrade}
>
Upgrade plan
</Button>
)}
</div>
</AlertDescription>
</Alert>
);

View File

@ -1,835 +0,0 @@
"use client";
import { SettingCard } from "@/components/settings";
import { ActionDialog, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SimpleTooltip, Switch, Typography, useToast } from "@/components/ui";
import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource } from "@hexclave/dashboard-ui-components";
import { cn } from "@/lib/utils";
import { DotsThreeIcon, PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react";
import { AdminEmailOutbox, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus } from "@hexclave/next";
import { fromNow } from "@hexclave/shared/dist/utils/dates";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
const STATUS_LABELS: Record<AdminEmailOutboxStatus, string> = {
"paused": "Paused",
"preparing": "Preparing",
"rendering": "Rendering",
"render-error": "Render Error",
"scheduled": "Scheduled",
"queued": "Queued",
"sending": "Sending",
"server-error": "Server Error",
"skipped": "Skipped",
"bounced": "Bounced",
"delivery-delayed": "Delivery Delayed",
"sent": "Sent",
"opened": "Opened",
"clicked": "Clicked",
"marked-as-spam": "Marked as Spam",
};
const SIMPLE_STATUS_LABELS: Record<AdminEmailOutboxSimpleStatus, string> = {
"in-progress": "In Progress",
"ok": "Completed",
"error": "Error",
};
function getStatusBadgeVariant(simpleStatus: AdminEmailOutboxSimpleStatus): "default" | "secondary" | "destructive" | "outline" {
switch (simpleStatus) {
case "ok": {
return "secondary";
}
case "error": {
return "destructive";
}
case "in-progress": {
return "default";
}
default: {
return "default";
}
}
}
function getRecipientDisplay(email: AdminEmailOutbox): string {
const to = email.to;
if (to.type === "user-primary-email") {
return `User: ${to.userId.slice(0, 8)}...`;
} else if (to.type === "user-custom-emails") {
return to.emails.join(", ") || `User: ${to.userId.slice(0, 8)}...`;
} else {
return to.emails.join(", ") || "No recipients";
}
}
// Helper to check if email is paused (avoids type narrowing issues)
function isEmailPaused(email: AdminEmailOutbox): boolean {
// Cast to string to avoid TypeScript complaining about exhaustive type narrowing
return (email.status as string) === "paused";
}
// Helper to check if we can pause - works with any email type
function canPauseEmail(email: AdminEmailOutbox): boolean {
const pausableStatuses = ["preparing", "rendering", "scheduled", "queued", "render-error", "server-error"];
return !isEmailPaused(email) && pausableStatuses.includes(email.status);
}
// Helper to check if we can cancel - works with any email type
function canCancelEmail(email: AdminEmailOutbox): boolean {
const cancellableStatuses = ["paused", "preparing", "rendering", "scheduled", "queued", "render-error", "server-error"];
return cancellableStatuses.includes(email.status);
}
function EmailActions({
email,
onRefresh,
}: {
email: AdminEmailOutbox,
onRefresh: () => Promise<void>,
}) {
const hexclaveAdminApp = useAdminApp();
const { toast } = useToast();
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const canPause = canPauseEmail(email);
const canUnpause = isEmailPaused(email);
const canCancel = canCancelEmail(email);
const handlePause = () => {
runAsynchronouslyWithAlert(async () => {
await hexclaveAdminApp.pauseOutboxEmail(email.id);
toast({
title: "Email paused",
description: "The email has been paused and will not be sent until unpaused.",
variant: "success",
});
await onRefresh();
});
};
const handleUnpause = () => {
runAsynchronouslyWithAlert(async () => {
await hexclaveAdminApp.unpauseOutboxEmail(email.id);
toast({
title: "Email unpaused",
description: "The email will continue processing.",
variant: "success",
});
await onRefresh();
});
};
const handleCancel = async () => {
await hexclaveAdminApp.cancelOutboxEmail(email.id);
toast({
title: "Email cancelled",
description: "The email has been cancelled and will not be sent.",
variant: "success",
});
setCancelDialogOpen(false);
await onRefresh();
};
if (!canPause && !canUnpause && !canCancel) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<DotsThreeIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canPause && (
<DropdownMenuItem onClick={handlePause}>
<PauseIcon className="mr-2 h-4 w-4" />
Pause
</DropdownMenuItem>
)}
{canUnpause && (
<DropdownMenuItem onClick={handleUnpause}>
<PlayIcon className="mr-2 h-4 w-4" />
Unpause
</DropdownMenuItem>
)}
{(canPause || canUnpause) && canCancel && <DropdownMenuSeparator />}
{canCancel && (
<DropdownMenuItem
onClick={() => setCancelDialogOpen(true)}
className="text-destructive focus:text-destructive"
>
<XCircleIcon className="mr-2 h-4 w-4" />
Cancel
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ActionDialog
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title="Cancel Email"
cancelButton
okButton={{
label: "Cancel Email",
onClick: handleCancel,
props: { variant: "destructive" },
}}
>
<Typography>
Are you sure you want to cancel this email? This action cannot be undone.
</Typography>
</ActionDialog>
</>
);
}
const EDITABLE_STATUSES: AdminEmailOutboxStatus[] = [
"paused", "preparing", "rendering", "render-error", "scheduled", "queued", "server-error",
];
function isEditable(email: AdminEmailOutbox): boolean {
return EDITABLE_STATUSES.includes(email.status);
}
// Helper type to extract optional properties from the discriminated union for display
type EmailDisplayData = {
// Rendering
startedRenderingAt?: Date,
renderedAt?: Date,
subject?: string,
isTransactional?: boolean,
isHighPriority?: boolean,
renderError?: string,
// Sending
startedSendingAt?: Date,
deliveredAt?: Date,
serverError?: string,
errorAt?: Date,
// Skipped
skippedAt?: Date,
skippedReason?: string,
skippedDetails?: Record<string, unknown>,
// Tracking
canHaveDeliveryInfo?: boolean,
bouncedAt?: Date,
deliveryDelayedAt?: Date,
openedAt?: Date,
clickedAt?: Date,
markedAsSpamAt?: Date,
};
// Extract display data from any email type
function getEmailDisplayData(email: AdminEmailOutbox): EmailDisplayData {
// Cast to any to access properties that may not exist on all variants
// This is safe because we're just extracting values for display
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = email as any;
return {
startedRenderingAt: e.startedRenderingAt,
renderedAt: e.renderedAt,
subject: e.subject,
isTransactional: e.isTransactional,
isHighPriority: e.isHighPriority,
renderError: e.renderError,
startedSendingAt: e.startedSendingAt,
deliveredAt: e.deliveredAt,
serverError: e.serverError,
errorAt: e.errorAt,
skippedAt: e.skippedAt,
skippedReason: e.skippedReason,
skippedDetails: e.skippedDetails,
canHaveDeliveryInfo: e.canHaveDeliveryInfo,
bouncedAt: e.bouncedAt,
deliveryDelayedAt: e.deliveryDelayedAt,
openedAt: e.openedAt,
clickedAt: e.clickedAt,
markedAsSpamAt: e.markedAsSpamAt,
};
}
function PropertyRow({
label,
value,
className,
}: {
label: string,
value: React.ReactNode,
className?: string,
}) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<Typography className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Typography>
<div className="text-sm">{value}</div>
</div>
);
}
function EmailDetailSheet({
email,
open,
onOpenChange,
onRefresh,
}: {
email: AdminEmailOutbox | null,
open: boolean,
onOpenChange: (open: boolean) => void,
onRefresh: () => Promise<void>,
}) {
const hexclaveAdminApp = useAdminApp();
const { toast } = useToast();
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Editable fields state
const [scheduledAt, setScheduledAt] = useState<string>("");
const [isPaused, setIsPaused] = useState(false);
// Initialize form when email changes
const initForm = (e: AdminEmailOutbox) => {
setScheduledAt(e.scheduledAt.toISOString().slice(0, 16));
setIsPaused(isEmailPaused(e));
};
// Reset form when sheet opens
if (email && open) {
// Only reset if values haven't been initialized yet
const expectedScheduledAt = email.scheduledAt.toISOString().slice(0, 16);
if (scheduledAt !== expectedScheduledAt && !isSaving) {
initForm(email);
}
}
if (!email) return null;
const editable = isEditable(email);
const displayData = getEmailDisplayData(email);
const handleSave = async () => {
setIsSaving(true);
try {
const updates: { isPaused?: boolean, scheduledAtMillis?: number } = {};
if (isPaused !== isEmailPaused(email)) {
updates.isPaused = isPaused;
}
const newScheduledAt = new Date(scheduledAt);
if (newScheduledAt.getTime() !== email.scheduledAt.getTime()) {
updates.scheduledAtMillis = newScheduledAt.getTime();
}
if (Object.keys(updates).length > 0) {
await hexclaveAdminApp.updateOutboxEmail(email.id, updates);
toast({
title: "Email updated",
description: "The email has been updated successfully.",
variant: "success",
});
await onRefresh();
}
onOpenChange(false);
} catch (error) {
toast({
title: "Failed to update email",
description: String(error),
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const handleCancel = async () => {
await hexclaveAdminApp.cancelOutboxEmail(email.id);
toast({
title: "Email cancelled",
description: "The email has been cancelled and will not be sent.",
variant: "success",
});
setCancelDialogOpen(false);
await onRefresh();
onOpenChange(false);
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>Email Details</SheetTitle>
<SheetDescription>
View and manage this email
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* Status Section */}
<div className="flex items-center gap-3">
<Badge variant={getStatusBadgeVariant(email.simpleStatus)} className="text-sm">
{STATUS_LABELS[email.status]}
</Badge>
{isEmailPaused(email) && (
<Badge variant="outline" className="text-sm">
<PauseIcon className="h-3 w-3 mr-1" />
Paused
</Badge>
)}
</div>
{/* Basic Info */}
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Basic Information</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.id}</code>} />
<PropertyRow label="Created" value={email.createdAt.toLocaleString()} />
<PropertyRow label="Updated" value={email.updatedAt.toLocaleString()} />
{editable ? (
<div className="flex flex-col gap-1">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Scheduled At</Label>
<Input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="h-8 text-sm"
/>
</div>
) : (
<PropertyRow label="Scheduled At" value={email.scheduledAt.toLocaleString()} />
)}
</div>
</div>
{/* Recipient Info */}
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Recipient</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Type" value={email.to.type} />
{email.to.type === "user-primary-email" && (
<PropertyRow label="User ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.to.userId}</code>} />
)}
{email.to.type === "user-custom-emails" && (
<>
<PropertyRow label="User ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.to.userId}</code>} />
<PropertyRow label="Emails" value={email.to.emails.join(", ") || "None"} className="col-span-2" />
</>
)}
{email.to.type === "custom-emails" && (
<PropertyRow label="Emails" value={email.to.emails.join(", ") || "None"} className="col-span-2" />
)}
</div>
</div>
{/* Rendering Info */}
{displayData.startedRenderingAt && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Rendering</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Started Rendering" value={displayData.startedRenderingAt.toLocaleString()} />
{displayData.renderedAt && (
<PropertyRow label="Rendered At" value={displayData.renderedAt.toLocaleString()} />
)}
{displayData.subject && (
<PropertyRow label="Subject" value={displayData.subject} className="col-span-2" />
)}
{displayData.isTransactional !== undefined && (
<PropertyRow label="Transactional" value={displayData.isTransactional ? "Yes" : "No"} />
)}
{displayData.isHighPriority !== undefined && (
<PropertyRow label="High Priority" value={displayData.isHighPriority ? "Yes" : "No"} />
)}
</div>
</div>
)}
{/* Render Error */}
{displayData.renderError && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold text-destructive">Render Error</Typography>
<pre className="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-x-auto whitespace-pre-wrap break-words">
{displayData.renderError}
</pre>
</div>
)}
{/* Sending Info */}
{displayData.startedSendingAt && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Sending</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Started Sending" value={displayData.startedSendingAt.toLocaleString()} />
{displayData.deliveredAt && (
<PropertyRow label="Delivered At" value={displayData.deliveredAt.toLocaleString()} />
)}
</div>
</div>
)}
{/* Server Error */}
{displayData.serverError && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold text-destructive">Server Error</Typography>
<pre className="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-x-auto whitespace-pre-wrap break-words">
{displayData.serverError}
</pre>
</div>
)}
{/* Skipped Info */}
{displayData.skippedAt && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Skipped</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Skipped At" value={displayData.skippedAt.toLocaleString()} />
{displayData.skippedReason && <PropertyRow label="Reason" value={displayData.skippedReason} />}
{displayData.skippedDetails && Object.keys(displayData.skippedDetails).length > 0 && (
<PropertyRow
label="Details"
value={<pre className="text-xs bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(displayData.skippedDetails, null, 2)}</pre>}
className="col-span-2"
/>
)}
</div>
</div>
)}
{/* Bounce Info */}
{displayData.bouncedAt && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold text-destructive">Bounced</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Bounced At" value={displayData.bouncedAt.toLocaleString()} />
</div>
</div>
)}
{/* Delivery Delayed Info */}
{displayData.deliveryDelayedAt && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Delivery Delayed</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Delayed At" value={displayData.deliveryDelayedAt.toLocaleString()} />
</div>
</div>
)}
{/* Delivery Tracking */}
{displayData.canHaveDeliveryInfo !== undefined && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Delivery Tracking</Typography>
<div className="grid grid-cols-2 gap-4">
<PropertyRow label="Tracking Available" value={displayData.canHaveDeliveryInfo ? "Yes" : "No"} />
{displayData.openedAt && <PropertyRow label="Opened At" value={displayData.openedAt.toLocaleString()} />}
{displayData.clickedAt && <PropertyRow label="Clicked At" value={displayData.clickedAt.toLocaleString()} />}
{displayData.markedAsSpamAt && <PropertyRow label="Marked as Spam At" value={displayData.markedAsSpamAt.toLocaleString()} />}
</div>
</div>
)}
{/* Controls Section */}
{editable && (
<div className="space-y-4 border-t pt-4">
<Typography className="font-semibold">Controls</Typography>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<Label>Paused</Label>
<Typography className="text-xs text-muted-foreground">Pause email processing</Typography>
</div>
<Switch
checked={isPaused}
onCheckedChange={setIsPaused}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-4 border-t">
{editable && (
<>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
<Button
variant="destructive"
onClick={() => setCancelDialogOpen(true)}
>
Cancel Email
</Button>
</>
)}
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</div>
</SheetContent>
</Sheet>
<ActionDialog
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title="Cancel Email"
cancelButton
okButton={{
label: "Cancel Email",
onClick: handleCancel,
props: { variant: "destructive" },
}}
>
<Typography>
Are you sure you want to cancel this email? This action cannot be undone.
</Typography>
</ActionDialog>
</>
);
}
const EMAIL_PAGE_SIZE = 50;
export default function PageClient() {
const hexclaveAdminApp = useAdminApp();
const [statusFilter, setStatusFilter] = useState<string>("all");
const [simpleStatusFilter, setSimpleStatusFilter] = useState<string>("all");
const [selectedEmail, setSelectedEmail] = useState<AdminEmailOutbox | null>(null);
const [detailSheetOpen, setDetailSheetOpen] = useState(false);
// Server-side infinite data source — cursor pagination against
// `listOutboxEmails`. Closure captures `statusFilter`/`simpleStatusFilter`
// so a filter change produces a new `dataSource` identity, which
// `useDataSource` uses to refetch from scratch.
const dataSource = useMemo<DataGridDataSource<AdminEmailOutbox>>(
() => async function* (params) {
const options: { status?: string, simpleStatus?: string, cursor?: string, limit?: number } = {
limit: EMAIL_PAGE_SIZE,
};
if (statusFilter !== "all") options.status = statusFilter;
if (simpleStatusFilter !== "all") options.simpleStatus = simpleStatusFilter;
if (typeof params.cursor === "string") options.cursor = params.cursor;
const result = await hexclaveAdminApp.listOutboxEmails(options);
yield {
rows: result.items,
hasMore: result.nextCursor != null,
nextCursor: result.nextCursor ?? undefined,
};
},
[hexclaveAdminApp, statusFilter, simpleStatusFilter],
);
const handleFilterChange = (newStatusFilter: string, newSimpleStatusFilter: string) => {
setStatusFilter(newStatusFilter);
setSimpleStatusFilter(newSimpleStatusFilter);
};
// Stable ref the `renderCell` closures reach through to trigger a
// refresh. Populated further below once `useDataSource` has returned its
// `reload` function.
const reloadRef = useRef<() => void>(() => {});
const emailColumns = useMemo<DataGridColumnDef<AdminEmailOutbox>[]>(() => [
{
id: "subject",
header: "Subject",
width: 200,
renderCell: ({ row }) => {
const subject = getEmailDisplayData(row).subject;
return (
<div className="truncate">
<SimpleTooltip tooltip={subject || "Not rendered yet"}>
<span>{subject || <span className="text-muted-foreground italic">Pending</span>}</span>
</SimpleTooltip>
</div>
);
},
},
{
id: "recipient",
header: "Recipient",
width: 150,
renderCell: ({ row }) => {
const display = getRecipientDisplay(row);
return (
<div className="truncate">
<SimpleTooltip tooltip={display}>
<span className="text-sm">{display}</span>
</SimpleTooltip>
</div>
);
},
},
{
id: "status",
header: "Status",
width: 150,
renderCell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge variant={getStatusBadgeVariant(row.simpleStatus)}>
{STATUS_LABELS[row.status]}
</Badge>
{isEmailPaused(row) && (
<SimpleTooltip tooltip="This email is paused">
<PauseIcon className="h-4 w-4 text-muted-foreground" />
</SimpleTooltip>
)}
</div>
),
},
{
id: "scheduled",
header: "Scheduled",
width: 130,
type: "dateTime",
accessor: "scheduledAt",
},
{
id: "created",
header: "Created",
width: 130,
type: "dateTime",
accessor: "createdAt",
},
{
id: "actions",
header: "",
width: 60,
sortable: false,
resizable: false,
renderCell: ({ row }) => <EmailActions email={row} onRefresh={async () => { reloadRef.current(); }} />,
},
], []);
const [emailGridState, setEmailGridState] = useDataGridUrlState(emailColumns, { paramPrefix: "outbox" });
const getRowId = useCallback((row: AdminEmailOutbox) => row.id, []);
const emailGridData = useDataSource({
dataSource,
columns: emailColumns,
getRowId,
sorting: emailGridState.sorting,
quickSearch: emailGridState.quickSearch,
pagination: emailGridState.pagination,
paginationMode: "infinite",
});
// Keep the ref pointed at the current `reload` so column `renderCell`
// closures built once still trigger a fresh fetch.
reloadRef.current = emailGridData.reload;
const handleRefresh = useCallback(async () => {
emailGridData.reload();
}, [emailGridData]);
const emails = emailGridData.rows;
return (
<PageLayout
title="Email Outbox"
description="View and manage scheduled and sent emails"
actions={
<Button onClick={() => runAsynchronouslyWithAlert(handleRefresh)} variant="outline">
Refresh
</Button>
}
>
<SettingCard title="Email Queue" description="All emails in the outbox">
<div className="flex gap-4 mb-4">
<div className="flex items-center gap-2">
<Typography className="text-sm font-medium">Status:</Typography>
<Select
value={statusFilter}
onValueChange={(value) => handleFilterChange(value, simpleStatusFilter)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{(Object.entries(STATUS_LABELS) as [AdminEmailOutboxStatus, string][]).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Typography className="text-sm font-medium">Category:</Typography>
<Select
value={simpleStatusFilter}
onValueChange={(value) => handleFilterChange(statusFilter, value)}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{(Object.entries(SIMPLE_STATUS_LABELS) as [AdminEmailOutboxSimpleStatus, string][]).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{emailGridData.isLoading ? (
<div className="flex justify-center py-8">
<Typography className="text-muted-foreground">Loading emails...</Typography>
</div>
) : emails.length === 0 ? (
<div className="flex justify-center py-8">
<Typography className="text-muted-foreground">No emails found</Typography>
</div>
) : (
<DataGrid
columns={emailColumns}
rows={emailGridData.rows}
getRowId={getRowId}
totalRowCount={emailGridData.totalRowCount}
state={emailGridState}
onChange={setEmailGridState}
paginationMode="infinite"
hasMore={emailGridData.hasMore}
isLoadingMore={emailGridData.isLoadingMore}
onLoadMore={emailGridData.loadMore}
footer={false}
fillHeight={false}
maxHeight={500}
toolbar={(ctx) => <DataGridToolbar ctx={ctx} hideQuickSearch />}
onRowClick={(row) => {
setSelectedEmail(row);
setDetailSheetOpen(true);
}}
/>
)}
</SettingCard>
<EmailDetailSheet
email={selectedEmail}
open={detailSheetOpen}
onOpenChange={(open) => {
setDetailSheetOpen(open);
if (!open) {
// Refresh the selected email from the list after closing
if (selectedEmail) {
const updated = emails.find(e => e.id === selectedEmail.id);
if (updated) {
setSelectedEmail(updated);
}
}
}
}}
onRefresh={async () => {
await handleRefresh();
// Update selected email with fresh data
if (selectedEmail) {
const updated = emails.find(e => e.id === selectedEmail.id);
if (updated) {
setSelectedEmail(updated);
}
}
}}
/>
</PageLayout>
);
}

View File

@ -1,13 +0,0 @@
import PageClient from "./page-client";
export const metadata = {
title: "Email Outbox",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -106,8 +106,8 @@ describe("Usage settings page", () => {
it("renders the plan, usage rows, and overage state", () => {
render(<PageClient />);
// The page title and usage card share this label.
expect(screen.getAllByText("Usage").length).toBeGreaterThan(0);
// The page title
expect(screen.getAllByText("Billing & Usage").length).toBeGreaterThan(0);
expect(screen.getAllByText("Free").length).toBeGreaterThan(0);
expect(screen.getAllByText("Owner").length).toBeGreaterThan(0);
expect(screen.getAllByText("Dashboard admins").length).toBeGreaterThan(0);

View File

@ -362,7 +362,7 @@ export default function PageClient() {
return (
<PageLayout
title="Usage"
title="Billing & Usage"
description={`Usage for ${planUsage.ownerTeamDisplayName} across all projects owned by this team.`}
width={1050}
>

View File

@ -139,7 +139,7 @@ const projectSettingsItem: AppSection = {
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/?$/.test(fullUrl.pathname),
},
{
name: "Usage",
name: "Billing & Usage",
href: "/project-settings/usage",
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/usage(\/.*)?$/.test(fullUrl.pathname),
},

View File

@ -7,7 +7,7 @@ import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, type Na
import { getUninstalledAppIds } from "@/lib/apps-utils";
import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query";
import { cn } from "@/lib/utils";
import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, EnvelopeSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react";
import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react";
import { ALL_APPS, ALL_APP_TAGS, getParentAppId, type AppId } from "@hexclave/shared/dist/apps/apps-config";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import Image from "next/image";
@ -308,15 +308,6 @@ const PROJECT_SHORTCUTS: ProjectShortcutDefinition[] = [
keywords: ["email themes", "themes", "branding", "style", "templates"],
requiredApps: ["emails"],
},
{
id: "emails/outbox",
icon: EnvelopeSimpleIcon,
label: "Email Outbox",
description: "Emails",
href: "/email-outbox",
keywords: ["email outbox", "outbox", "delivery", "queue", "scheduled emails"],
requiredApps: ["emails"],
},
{
id: "data-vault/stores",
icon: HardDriveIcon,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/dev-launchpad",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/e2e-tests",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,9 +1,42 @@
import { randomUUID } from "node:crypto";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
import { wait } from "@hexclave/shared/dist/utils/promises";
import { it } from "../../../../helpers";
import { Auth, bumpEmailAddress, niceBackendFetch, Payments, Project, Team } from "../../../backend-helpers";
import { getOutboxEmails } from "./emails/email-helpers";
// Stripe webhook events are now deduplicated globally by their `event.id` (see
// the StripeWebhookEvent table). The dev DB is NOT reset between test runs, so
// every claimed event needs a per-run unique id, otherwise a second run would
// hit the dedupe path and skip processing.
function uniqueEventId(prefix: string) {
return `evt_${prefix}_${randomUUID()}`;
}
// Webhook processing now happens in the background after a fast 200 ack, so DB
// state is eventually-consistent from the test's perspective. Poll instead of
// reading immediately after the webhook returns.
async function waitForItemQuantity(
args: { customerType: "user" | "team", customerId: string, itemId: string, expected: number },
) {
let last: number | undefined;
for (let i = 0; i < 30; i++) {
const res = await niceBackendFetch(
`/api/latest/payments/items/${args.customerType}/${args.customerId}/${args.itemId}`,
{ accessType: "client" },
);
if (res.status !== 200) {
throw new Error(`Unexpected ${res.status} reading item ${args.itemId}`);
}
last = res.body.quantity;
if (last === args.expected) {
return;
}
await wait(500);
}
throw new Error(`Item ${args.itemId} quantity never reached ${args.expected} (last seen: ${last})`);
}
async function waitForOutboxEmail(subject: string) {
for (let i = 0; i < 30; i++) {
const emails = await getOutboxEmails({ subject });
@ -26,14 +59,20 @@ async function waitForNoOutboxEmail(subject: string) {
}
it("rejects signed mock_event.succeeded webhook", async ({ expect }) => {
it("acks unknown signed webhook types (errors handled in background)", async ({ expect }) => {
// We now persist + ack the event synchronously and process it in the
// background, so an unknown type no longer surfaces a 500 to Stripe. The
// "Unknown stripe webhook type" error is captured async and the event row is
// marked FAILED (covered deterministically in stripe-webhook-events.test.ts).
const payload = {
id: "evt_test_1",
id: uniqueEventId("mock_event_succeeded"),
type: "mock_event.succeeded",
account: "acct_test123",
data: { object: { customer: "cus_test123", metadata: {} } },
};
await expect(Payments.sendStripeWebhook(payload)).rejects.toThrow(/Unknown stripe webhook type received/);
const res = await Payments.sendStripeWebhook(payload);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
});
it("returns 400 on invalid signature", async ({ expect }) => {
@ -53,15 +92,17 @@ it("returns 400 on invalid signature", async ({ expect }) => {
`);
});
it("returns 500 on unknown webhook type", async ({ expect }) => {
it("acks unknown webhook types with 200 (errors handled in background)", async ({ expect }) => {
const payload = {
id: "evt_test_unknown",
id: uniqueEventId("unknown_event"),
type: "unknown.event",
account: "acct_test123",
data: { object: {} },
};
await expect(Payments.sendStripeWebhook(payload)).rejects.toThrow(/Unknown stripe webhook type received/);
const res = await Payments.sendStripeWebhook(payload);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
});
it("returns 400 when signature header is missing (schema validation)", async ({ expect }) => {
@ -86,7 +127,7 @@ it("accepts chargeback webhooks", async ({ expect }) => {
const accountId: string = accountInfo.body.account_id;
const payload = {
id: "evt_chargeback_test",
id: uniqueEventId("chargeback"),
type: "charge.dispute.created",
account: accountId,
data: {
@ -172,7 +213,7 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({
const fullCode = purchaseUrl.split("/purchase/")[1];
const stackTestTenancyId = fullCode.split("_")[0];
const payloadObj = {
id: "evt_retry_test",
id: uniqueEventId("retry"),
type: "payment_intent.succeeded",
account: accountId,
data: {
@ -198,18 +239,18 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({
};
const res = await Payments.sendStripeWebhook(payloadObj);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
expect(res.body.received).toBe(true);
// First grant must land before we redeliver, so the duplicate deterministically
// hits the event-dedupe path (PROCESSED) rather than racing in-flight work.
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
const res2 = await Payments.sendStripeWebhook(payloadObj);
expect(res2.status).toBe(200);
expect(res2.body).toEqual({ received: true });
expect(res2.body).toEqual({ received: true, deduplicated: true });
// After duplicate deliveries, quantity should reflect a single OneTimePurchase grant
const getAfter = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
accessType: "client",
});
expect(getAfter.status).toBe(200);
expect(getAfter.body.quantity).toBe(1);
// After the deduplicated redelivery, quantity stays at a single grant.
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
});
it("sends a payment receipt email for one-time purchases", async ({ expect }) => {
@ -268,7 +309,7 @@ it("sends a payment receipt email for one-time purchases", async ({ expect }) =>
const receiptLink = "https://example.com/receipt/pi_test_receipt_1";
const paymentIntentId = "pi_test_receipt_1";
const payloadObj = {
id: "evt_receipt_test_1",
id: uniqueEventId("receipt"),
type: "payment_intent.succeeded",
account: accountId,
data: {
@ -313,6 +354,117 @@ it("sends a payment receipt email for one-time purchases", async ({ expect }) =>
`);
});
it("sends exactly one receipt when Stripe redelivers the same event", async ({ expect }) => {
// Regression test for the duplicate-receipt bug: Stripe delivers at-least-once,
// and slow synchronous processing used to time out and trigger redeliveries,
// each re-sending the receipt fan-out. The StripeWebhookEvent dedupe must keep
// the fan-out to exactly once per event id.
const projectDisplayName = `Receipt Idempotency ${randomUUID()}`;
await Project.createAndSwitch({ display_name: projectDisplayName });
await Payments.setup();
const itemId = "idem-receipt-credits";
const productId = "idem-receipt-ot";
const product = {
displayName: "Idem Receipt Pack",
customerType: "user",
serverOnly: false,
stackable: true,
prices: { one: { USD: "500" } },
includedItems: { [itemId]: { quantity: 1 } },
};
await Project.updateConfig({
payments: {
items: {
[itemId]: { displayName: "Credits", customerType: "user" },
},
products: {
[productId]: product,
},
},
});
const mailbox = await bumpEmailAddress();
const { userId } = await Auth.fastSignUp({
primary_email: mailbox.emailAddress,
primary_email_verified: true,
});
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: productId,
},
});
expect(createUrlResponse.status).toBe(200);
const purchaseUrl = (createUrlResponse.body as { url: string }).url;
const fullCode = purchaseUrl.split("/purchase/")[1];
const stackTestTenancyId = fullCode.split("_")[0];
const receiptLink = "https://example.com/receipt/pi_idem_receipt";
const eventId = uniqueEventId("idem_receipt");
const payloadObj = {
id: eventId,
type: "payment_intent.succeeded",
account: accountId,
data: {
object: {
id: "pi_idem_receipt",
customer: userId,
amount_received: 500,
currency: "usd",
charges: { data: [{ receipt_url: receiptLink }] },
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
"subscriptions.list": { data: [] },
},
metadata: {
productId,
product: JSON.stringify(product),
customerId: userId,
customerType: "user",
purchaseQuantity: "1",
purchaseKind: "ONE_TIME",
priceId: "one",
},
},
},
};
const first = await Payments.sendStripeWebhook(payloadObj);
expect(first.status).toBe(200);
expect(first.body).toEqual({ received: true });
// Wait for the receipt to land, which proves the first event finished
// processing (and is now PROCESSED), so the redeliveries deterministically
// take the dedupe path.
const subject = `Your receipt from ${projectDisplayName}`;
await waitForOutboxEmail(subject);
for (let i = 0; i < 2; i++) {
const redelivery = await Payments.sendStripeWebhook(payloadObj);
expect(redelivery.status).toBe(200);
expect(redelivery.body).toEqual({ received: true, deduplicated: true });
}
// Give any (incorrectly) re-triggered fan-out a chance to show up, then assert
// there is still exactly one receipt email for this project.
await wait(1500);
const receipts = await getOutboxEmails({ subject });
expect(receipts.length).toBe(1);
});
it("sends a payment failed email for invoice.payment_failed", async ({ expect }) => {
const projectDisplayName = "Payments Failed Email Test";
await Project.createAndSwitch({ display_name: projectDisplayName });
@ -365,7 +517,7 @@ it("sends a payment failed email for invoice.payment_failed", async ({ expect })
const invoiceId = "in_test_failed_1";
const invoiceUrl = "https://example.com/billing/update";
const payloadObj = {
id: "evt_invoice_failed_1",
id: uniqueEventId("invoice_failed"),
type: "invoice.payment_failed",
account: accountId,
data: {
@ -458,7 +610,7 @@ it("skips payment failed email when invoice is not uncollectible", async ({ expe
const invoiceId = "in_test_failed_open_1";
const invoiceUrl = "https://example.com/billing/open";
const payloadObj = {
id: "evt_invoice_failed_open_1",
id: uniqueEventId("invoice_failed_open"),
type: "invoice.payment_failed",
account: accountId,
data: {
@ -569,7 +721,7 @@ it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => {
};
const payloadObj = {
id: "evt_sub_sync_1",
id: uniqueEventId("sub_sync"),
type: "invoice.paid",
account: accountId,
data: {
@ -586,23 +738,16 @@ it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => {
const res = await Payments.sendStripeWebhook(payloadObj);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
expect(res.body.received).toBe(true);
const getAfter1 = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
accessType: "client",
});
expect(getAfter1.status).toBe(200);
expect(getAfter1.body.quantity).toBe(1);
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
// Redelivery of the same event id is deduplicated and leaves state untouched.
const res2 = await Payments.sendStripeWebhook(payloadObj);
expect(res2.status).toBe(200);
expect(res2.body).toEqual({ received: true });
expect(res2.body).toEqual({ received: true, deduplicated: true });
const getAfter2 = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
accessType: "client",
});
expect(getAfter2.status).toBe(200);
expect(getAfter2.body.quantity).toBe(1);
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
});
@ -682,7 +827,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
};
const payloadAdd = {
id: "evt_sub_add",
id: uniqueEventId("sub_add"),
type: "invoice.paid",
account: accountId,
data: {
@ -701,11 +846,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
expect(resAdd.status).toBe(200);
expect(resAdd.body).toEqual({ received: true });
const afterAdd = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
accessType: "client",
});
expect(afterAdd.status).toBe(200);
expect(afterAdd.body.quantity).toBe(1);
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 1 });
const canceledSubscription = {
...activeSubscription,
@ -722,7 +863,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
};
const payloadRemove = {
id: "evt_sub_remove",
id: uniqueEventId("sub_remove"),
type: "customer.subscription.updated",
account: accountId,
data: {
@ -741,11 +882,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
expect(resRemove.status).toBe(200);
expect(resRemove.body).toEqual({ received: true });
const afterRemove = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/${itemId}`, {
accessType: "client",
});
expect(afterRemove.status).toBe(200);
expect(afterRemove.body.quantity).toBe(0);
await waitForItemQuantity({ customerType: "user", customerId: userId, itemId, expected: 0 });
});
@ -796,7 +933,7 @@ it("does NOT auto-grant `free` when a non-internal tenancy's sub is canceled via
const nowSec = Math.floor(Date.now() / 1000);
const webhookResponse = await Payments.sendStripeWebhook({
id: "evt_customer_cancel",
id: uniqueEventId("customer_cancel"),
type: "customer.subscription.deleted",
account: accountId,
data: {

View File

@ -1,7 +1,7 @@
{
"name": "@hexclave/hosted-components",
"private": true,
"version": "1.0.28",
"version": "1.0.29",
"type": "module",
"scripts": {
"dev": "vite dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}09",

View File

@ -1,7 +1,7 @@
{
"name": "@hexclave/internal-tool",
"private": true,
"version": "1.0.28",
"version": "1.0.29",
"type": "module",
"scripts": {
"dev": "node scripts/pre-dev.mjs && next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/mcp",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/mock-oauth-server",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"main": "index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/skills",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/docs-mintlify",
"version": "1.0.28",
"version": "1.0.29",
"private": true,
"scripts": {
"dev": "mint dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}04 --no-open",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/docs",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"description": "",
"main": "index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-cjs-test",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/convex-example",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-demo-app",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"description": "",
"private": true,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/docs-examples",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"description": "",
"private": true,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/e-commerce-demo",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/js-example",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"description": "",

View File

@ -1,7 +1,7 @@
{
"name": "@hexclave/lovable-react-18-example",
"private": true,
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-middleware-demo",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,7 +1,7 @@
{
"name": "react-example",
"private": true,
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-supabase",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-tanstack-start-demo",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"description": "TanStack Start demo app for Hexclave",
"private": true,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/cli",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"description": "The CLI for Hexclave. https://hexclave.com",
"main": "dist/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/dashboard-ui-components",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/js",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",
@ -76,7 +76,6 @@
"@oslojs/otp": "^1.1.0",
"qrcode": "^1.5.4",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"devDependencies": {
@ -97,6 +96,7 @@
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"convex": "^1.27.0"
}
}

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/next",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",
@ -93,7 +93,6 @@
"react-hook-form": "^7.70.0",
"tailwindcss-animate": "^1.0.7",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"peerDependencies": {
@ -132,6 +131,7 @@
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"convex": "^1.27.0"
}
}

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/react",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",
@ -92,7 +92,6 @@
"react-hook-form": "^7.70.0",
"tailwindcss-animate": "^1.0.7",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"peerDependencies": {
@ -129,6 +128,7 @@
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"convex": "^1.27.0"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/sc",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"exports": {
"./force-react-server": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/shared-backend",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/shared",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"scripts": {
"build": "rimraf dist && tsdown",

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/tanstack-start",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",
@ -103,7 +103,6 @@
"react-hook-form": "^7.70.0",
"tailwindcss-animate": "^1.0.7",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"peerDependencies": {
@ -144,6 +143,7 @@
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"convex": "^1.27.0"
}
}

View File

@ -13,7 +13,7 @@
"//": "NEXT_LINE_PLATFORM template",
"private": true,
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",
@ -146,7 +146,6 @@
"//": "NEXT_LINE_PLATFORM react-like",
"tailwindcss-animate": "^1.0.7",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"//": "IF_PLATFORM react-like",
@ -202,6 +201,7 @@
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"convex": "^1.27.0"
}
}

View File

@ -2,7 +2,7 @@
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/template",
"private": true,
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",
@ -109,7 +109,6 @@
"react-hook-form": "^7.70.0",
"tailwindcss-animate": "^1.0.7",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"peerDependencies": {
@ -152,6 +151,7 @@
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"convex": "^1.27.0"
}
}

View File

@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { envVars } from "../generated/env";
import { StackClientApp } from "../lib/hexclave-app";
import { mountPushedConfigErrorOverlay } from ".";
function createMockElement() {
return {
style: {},
appendChild: () => {},
addEventListener: () => {},
setAttribute: () => {},
replaceChildren: () => {},
remove: () => {},
};
}
describe("pushed config error overlay", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it("defers the first project refresh until after construction-time callers finish", async () => {
const app = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000000",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "none",
devTool: false,
});
const getProject = vi.fn(async () => ({
pushedConfigError: null,
configWarnings: [],
}));
Reflect.set(app, "getProject", getProject);
const appendChild = vi.fn();
vi.stubEnv("NODE_ENV", "development");
expect(Reflect.get(envVars, "NODE_ENV")).toBe("development");
vi.stubGlobal("window", {
"__hexclave-pushed-config-error-overlay": null,
location: {
href: "http://localhost:3000",
},
});
vi.stubGlobal("document", {
body: {
appendChild,
},
createElement: () => createMockElement(),
createTextNode: () => createMockElement(),
});
vi.stubGlobal("localStorage", {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
});
const cleanup = mountPushedConfigErrorOverlay(app);
try {
expect(appendChild).toHaveBeenCalledOnce();
expect(getProject).not.toHaveBeenCalled();
await Promise.resolve();
expect(getProject).toHaveBeenCalledOnce();
} finally {
cleanup();
}
});
});

View File

@ -528,7 +528,10 @@ export function mountPushedConfigErrorOverlay(app: StackClientApp<true>): () =>
});
};
refresh();
// This is mounted from the base client-app constructor, which also runs
// before subclass field initializers. Defer the first app call so overridden
// methods like adminApp.getProject() can safely touch subclass caches.
queueMicrotask(refresh);
const interval = setInterval(refresh, REFRESH_INTERVAL_MS);
const cleanup = () => {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/ui",
"version": "1.0.28",
"version": "1.0.29",
"repository": "https://github.com/hexclave/hexclave",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1981,9 +1981,6 @@ importers:
rrweb:
specifier: ^1.1.3
version: 1.1.3
tsx:
specifier: ^4.21.0
version: 4.21.0
yup:
specifier: ^1.7.1
version: 1.7.1
@ -2042,6 +2039,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
packages/next:
dependencies:
@ -2117,9 +2117,6 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.0))
tsx:
specifier: ^4.21.0
version: 4.21.0
yup:
specifier: ^1.7.1
version: 1.7.1
@ -2187,6 +2184,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
packages/react:
dependencies:
@ -2259,9 +2259,6 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.14)
tsx:
specifier: ^4.21.0
version: 4.21.0
yup:
specifier: ^1.7.1
version: 1.7.1
@ -2326,6 +2323,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
packages/sc:
dependencies:
@ -2536,9 +2536,6 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.0))
tsx:
specifier: ^4.21.0
version: 4.21.0
yup:
specifier: ^1.7.1
version: 1.7.1
@ -2609,6 +2606,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
packages/template:
dependencies:
@ -2684,9 +2684,6 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.14)
tsx:
specifier: ^4.21.0
version: 4.21.0
yup:
specifier: ^1.7.1
version: 1.7.1
@ -2760,6 +2757,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
packages/ui:
dependencies:

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/swift-sdk",
"version": "1.0.28",
"version": "1.0.29",
"private": true,
"description": "Hexclave Swift SDK",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/sdk-spec",
"version": "1.0.28",
"version": "1.0.29",
"private": true,
"description": "Hexclave SDK specification files",
"scripts": {}