Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/app/api/auth/sso/recovery/complete/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
import { getSession } from "@/modules/auth/lib/session";
import {
BETTER_AUTH_SESSION_COOKIE_NAMES,
getSessionTokenFromCookieHeader,
} from "@/modules/auth/lib/session-cookie";
import { revokeSessionByToken } from "@/modules/auth/lib/session-revocation";
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";

const clearSessionCookies = (response: NextResponse) => {
Expand All @@ -31,7 +31,9 @@ const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: strin
}

try {
await deleteSessionBySessionToken(sessionToken);
// Through the two-store revocation, not a raw Prisma delete: sessions live in Redis too, and a
// DB-only delete would leave this one resolvable by `getSession` until its TTL (ENG-2557).
await revokeSessionByToken(sessionToken);
} catch (error) {
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
}
Expand All @@ -52,6 +54,8 @@ export const GET = async (request: Request) => {
const callbackUrl = await completeSsoRecovery({
intentToken,
sessionUserId: session?.user.id,
// Spared by the post-commit session sweep, so the redirect below still lands signed in.
sessionToken: getSessionTokenFromCookieHeader(request.headers.get("cookie")) ?? undefined,
});

return NextResponse.redirect(callbackUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/prisma";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { PUBLIC_API_SURVEY_NAME_PLACEHOLDER } from "@formbricks/types/js-constants";
import { getWorkspaceStateData } from "./data";

vi.mock("server-only", () => ({}));
Expand Down Expand Up @@ -120,7 +121,7 @@ describe("getWorkspaceStateData", () => {
surveys: [
{
...mockWorkspaceData.surveys[0],
name: "[deprecated] survey name omitted from public API - will be removed soon",
name: PUBLIC_API_SURVEY_NAME_PLACEHOLDER,
},
],
actionClasses: mockWorkspaceData.actionClasses,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TJsWorkspaceStateSurvey,
TJsWorkspaceStateWorkspaceSetting,
} from "@formbricks/types/js";
import { PUBLIC_API_SURVEY_NAME_PLACEHOLDER } from "@formbricks/types/js-constants";
import { type TBaseFilters, buildSurveyInteractionRefreshMap } from "@formbricks/types/segment";
import { toLegacyLanguageCodes } from "@/lib/i18n/utils";
import { validateInputs } from "@/lib/utils/validate";
Expand Down Expand Up @@ -283,7 +284,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp

return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
name: PUBLIC_API_SURVEY_NAME_PLACEHOLDER,
segment: sanitizedSegment,
...(interactionRefresh ? { interactionRefresh } : {}),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, test } from "vitest";
import type { EnrichmentStatusResponse } from "@/modules/hub/types";
import { aggregateEnrichmentStatus } from "./aggregate";

const status = (
overrides: Partial<
Record<"translation" | "sentiment" | "emotions", Partial<EnrichmentStatusResponse["translation"]>>
>
): EnrichmentStatusResponse => ({
tenant_id: "frd-1",
translation: { enabled: false, eligible: 0, done: 0, ...overrides.translation },
sentiment: { enabled: false, eligible: 0, done: 0, ...overrides.sentiment },
emotions: { enabled: false, eligible: 0, done: 0, ...overrides.emotions },
});

describe("aggregateEnrichmentStatus", () => {
test("derives pending as eligible minus done", () => {
const result = aggregateEnrichmentStatus([
status({ translation: { enabled: true, eligible: 500, done: 480 } }),
]);

expect(result).toEqual([
{ kind: "translation", eligible: 500, done: 480, failedTerminal: 0, pending: 20 },
]);
});

test("drops enrichments that are disabled everywhere", () => {
const result = aggregateEnrichmentStatus([
status({
translation: { enabled: true, eligible: 500, done: 500 },
sentiment: { enabled: true, eligible: 500, done: 200 },
}),
]);

expect(result.map((enrichment) => enrichment.kind)).toEqual(["translation", "sentiment"]);
});

test("returns nothing when no enrichment is enabled", () => {
expect(aggregateEnrichmentStatus([status({})])).toEqual([]);
});

test("drops an enrichment that's enabled but has no eligible records anywhere", () => {
// e.g. translation on, but every record is already in the target language — a 0/0 bar can never
// move, the same reason a fully-disabled enrichment is dropped.
const result = aggregateEnrichmentStatus([
status({ translation: { enabled: true, eligible: 0, done: 0 } }),
]);

expect(result).toEqual([]);
});

test("sums the counts across directories", () => {
const result = aggregateEnrichmentStatus([
status({ sentiment: { enabled: true, eligible: 300, done: 100 } }),
status({ sentiment: { enabled: true, eligible: 200, done: 150 } }),
]);

expect(result).toEqual([
{ kind: "sentiment", eligible: 500, done: 250, failedTerminal: 0, pending: 250 },
]);
});

test("ignores directories where the enrichment is switched off", () => {
// The disabled directory reports zeros; counting it would only inflate the denominator with
// records that can never be enriched.
const result = aggregateEnrichmentStatus([
status({ emotions: { enabled: true, eligible: 400, done: 100 } }),
status({ emotions: { enabled: false, eligible: 0, done: 0 } }),
]);

expect(result).toEqual([{ kind: "emotions", eligible: 400, done: 100, failedTerminal: 0, pending: 300 }]);
});

test("keeps an enrichment enabled on only one of several directories", () => {
const result = aggregateEnrichmentStatus([
status({ translation: { enabled: false, eligible: 0, done: 0 } }),
status({ translation: { enabled: true, eligible: 120, done: 120 } }),
]);

expect(result).toEqual([
{ kind: "translation", eligible: 120, done: 120, failedTerminal: 0, pending: 0 },
]);
});

test("never reports a negative pending count", () => {
const result = aggregateEnrichmentStatus([
status({ sentiment: { enabled: true, eligible: 10, done: 12 } }),
]);

expect(result[0].pending).toBe(0);
});

test("treats an enrichment missing from the Hub response as disabled", () => {
// sentiment/emotions genuinely absent, not just zeroed — legal per the response type, which
// types all three keys as optional for exactly this case.
const partial: EnrichmentStatusResponse = {
tenant_id: "frd-1",
translation: { enabled: true, eligible: 50, done: 25 },
};

const result = aggregateEnrichmentStatus([partial]);

expect(result).toEqual([{ kind: "translation", eligible: 50, done: 25, failedTerminal: 0, pending: 25 }]);
});

test("returns nothing when there are no directories", () => {
expect(aggregateEnrichmentStatus([])).toEqual([]);
});

// ENG-2375: a record whose enrichment permanently gave up (content filter, refusal, truncation)
// used to be silently folded into `eligible - done` and read as "still in progress" forever.
test("excludes permanently-failed records from pending", () => {
const result = aggregateEnrichmentStatus([
status({ sentiment: { enabled: true, eligible: 100, done: 80, failed_terminal: 15 } }),
]);

expect(result).toEqual([{ kind: "sentiment", eligible: 100, done: 80, failedTerminal: 15, pending: 5 }]);
});

test("sums failed_terminal across directories", () => {
const result = aggregateEnrichmentStatus([
status({ emotions: { enabled: true, eligible: 200, done: 150, failed_terminal: 10 } }),
status({ emotions: { enabled: true, eligible: 100, done: 60, failed_terminal: 5 } }),
]);

expect(result).toEqual([{ kind: "emotions", eligible: 300, done: 210, failedTerminal: 15, pending: 75 }]);
});

test("never reports a negative pending count when failed_terminal alone exceeds the remainder", () => {
// Shouldn't happen per the Hub's own invariant (done + failed + failed_terminal <= eligible), but
// clamp defensively rather than surface a negative number if it ever does.
const result = aggregateEnrichmentStatus([
status({ translation: { enabled: true, eligible: 10, done: 5, failed_terminal: 8 } }),
]);

expect(result[0].pending).toBe(0);
});

test("treats a missing failed_terminal as zero, for a Hub that predates the field", () => {
const result = aggregateEnrichmentStatus([
status({ sentiment: { enabled: true, eligible: 50, done: 30 } }),
]);

expect(result[0].failedTerminal).toBe(0);
expect(result[0].pending).toBe(20);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
ENRICHMENT_KINDS,
type TEnrichmentProgress,
} from "@/modules/ee/unify-feedback/enrichment-status/lib/enrichment";
import type { EnrichmentStatusResponse, EnrichmentTypeStatus } from "@/modules/hub/types";

/**
* Fold the per-directory Hub responses into one progress row per enrichment.
*
* The Feedback Data page merges records from every directory assigned to the workspace and offers no
* directory selector, so the indicator above the table has to speak for the same set. Records are
* partitioned by directory, so summing the per-directory counts gives workspace totals without
* double-counting.
*
* Only directories where the enrichment is switched on contribute: a disabled directory reports zeros,
* and folding those in would drag a shared progress bar toward a denominator that can never grow. An
* enrichment disabled everywhere is dropped entirely — and so is one that's enabled but has no eligible
* records anywhere (e.g. translation on, but every record already in the target language), for the
* same reason: a 0/0 bar can never move, whether the zero comes from being off or from having nothing
* to do.
*/
export function aggregateEnrichmentStatus(
statuses: readonly EnrichmentStatusResponse[]
): TEnrichmentProgress[] {
const enrichments: TEnrichmentProgress[] = [];

for (const kind of ENRICHMENT_KINDS) {
// Tolerate a Hub that predates a given enrichment: an absent key reads as disabled, not as NaN.
const enabledStatuses = statuses
.map((status) => status[kind])
.filter((status): status is EnrichmentTypeStatus => Boolean(status?.enabled));
if (enabledStatuses.length === 0) continue;

const eligible = enabledStatuses.reduce((sum, status) => sum + (status.eligible || 0), 0);
if (eligible === 0) continue;

const done = enabledStatuses.reduce((sum, status) => sum + (status.done || 0), 0);
// Bridged field (ENG-2375) — absent on a Hub that predates it, reads as 0 rather than NaN.
const failedTerminal = enabledStatuses.reduce((sum, status) => sum + (status.failed_terminal || 0), 0);

enrichments.push({
kind,
eligible,
done,
failedTerminal,
pending: Math.max(0, eligible - done - failedTerminal),
});
}

return enrichments;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { requireUnifyFeedbackWorkspaceAccess } from "@/app/api/v3/lib/feedback-access";
import type { V3WorkspaceContext } from "@/app/api/v3/lib/workspace-context";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import type { TEnrichmentStatusResponse } from "@/modules/ee/unify-feedback/enrichment-status/lib/enrichment";
import { getEnrichmentStatus } from "@/modules/hub/service";
import type { EnrichmentStatusResponse } from "@/modules/hub/types";
import { NO_CONFIG_ERROR } from "@/modules/hub/utils";
import { getV3EnrichmentStatus } from "./operations";

vi.mock("server-only", () => ({}));

vi.mock("@/app/api/v3/lib/feedback-access", () => ({
requireUnifyFeedbackWorkspaceAccess: vi.fn(),
}));

vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
getFeedbackDirectoriesByWorkspaceId: vi.fn(),
}));

vi.mock("@/modules/hub/service", () => ({
getEnrichmentStatus: vi.fn(),
}));

const workspaceId = "clxx1234567890123456789012";
const context: V3WorkspaceContext = { workspaceId, organizationId: "org_1" };
const base = { authentication: null, workspaceId, requestId: "req_1", instance: "/x" };

const hubStatus = (tenantId: string, done: number): EnrichmentStatusResponse => ({
tenant_id: tenantId,
translation: { enabled: true, eligible: 100, done },
sentiment: { enabled: false, eligible: 0, done: 0 },
emotions: { enabled: false, eligible: 0, done: 0 },
});

const readBody = async (response: Response): Promise<TEnrichmentStatusResponse> =>
((await response.json()) as { data: TEnrichmentStatusResponse }).data;

describe("getV3EnrichmentStatus", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireUnifyFeedbackWorkspaceAccess).mockResolvedValue(context);
});

test("short-circuits on the authorization response without reaching the Hub", async () => {
const forbidden = new Response(null, { status: 403 });
vi.mocked(requireUnifyFeedbackWorkspaceAccess).mockResolvedValue(forbidden);

const response = await getV3EnrichmentStatus(base);

expect(response).toBe(forbidden);
expect(getFeedbackDirectoriesByWorkspaceId).not.toHaveBeenCalled();
expect(getEnrichmentStatus).not.toHaveBeenCalled();
});

test("resolves the tenant ids from the workspace's own directories", async () => {
vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([
{ id: "frd-1" },
{ id: "frd-2" },
] as Awaited<ReturnType<typeof getFeedbackDirectoriesByWorkspaceId>>);
vi.mocked(getEnrichmentStatus).mockImplementation(async (tenantId: string) => ({
data: hubStatus(tenantId, 40),
error: null,
}));

const response = await getV3EnrichmentStatus(base);

expect(getFeedbackDirectoriesByWorkspaceId).toHaveBeenCalledWith(workspaceId);
expect(getEnrichmentStatus).toHaveBeenCalledWith("frd-1");
expect(getEnrichmentStatus).toHaveBeenCalledWith("frd-2");
await expect(readBody(response)).resolves.toEqual({
enrichments: [{ kind: "translation", eligible: 200, done: 80, failedTerminal: 0, pending: 120 }],
unavailable: false,
});
});

test("reports no enrichments when the workspace has no directory", async () => {
vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([]);

const response = await getV3EnrichmentStatus(base);

expect(getEnrichmentStatus).not.toHaveBeenCalled();
await expect(readBody(response)).resolves.toEqual({ enrichments: [], unavailable: false });
});

test("degrades to unavailable rather than erroring when every directory fails", async () => {
vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: "frd-1" }] as Awaited<
ReturnType<typeof getFeedbackDirectoriesByWorkspaceId>
>);
vi.mocked(getEnrichmentStatus).mockResolvedValue({ data: null, error: { ...NO_CONFIG_ERROR } });

const response = await getV3EnrichmentStatus(base);

expect(response.status).toBe(200);
await expect(readBody(response)).resolves.toEqual({ enrichments: [], unavailable: true });
});

test("keeps the directories that answered when one of them fails", async () => {
vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([
{ id: "frd-1" },
{ id: "frd-2" },
] as Awaited<ReturnType<typeof getFeedbackDirectoriesByWorkspaceId>>);
vi.mocked(getEnrichmentStatus).mockImplementation(async (tenantId: string) =>
tenantId === "frd-1"
? { data: hubStatus(tenantId, 25), error: null }
: { data: null, error: { ...NO_CONFIG_ERROR } }
);

const response = await getV3EnrichmentStatus(base);

// The failed directory is left out entirely — counting it as zero-done would invent a backlog.
await expect(readBody(response)).resolves.toEqual({
enrichments: [{ kind: "translation", eligible: 100, done: 25, failedTerminal: 0, pending: 75 }],
unavailable: false,
});
});
});
Loading
Loading