mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Merge branch 'dev' into setup-page-redo
This commit is contained in:
commit
78a3d220e0
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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();
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
122
apps/backend/src/lib/stripe-webhook-events.test.ts
Normal file
122
apps/backend/src/lib/stripe-webhook-events.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
60
apps/backend/src/lib/stripe-webhook-events.ts
Normal file
60
apps/backend/src/lib/stripe-webhook-events.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.29",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Email Outbox",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": "",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/swift-sdk",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.29",
|
||||
"private": true,
|
||||
"description": "Hexclave Swift SDK",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sdk-spec",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.29",
|
||||
"private": true,
|
||||
"description": "Hexclave SDK specification files",
|
||||
"scripts": {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user