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
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ describe("getSurveys", () => {
status: {
not: "completed",
},
archivedAt: null,
},
select: selectSurvey,
orderBy: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const getSurveys = reactCache(async (workspaceId: string): Promise<TSurve
status: {
not: "completed",
},
// Archived surveys must not be selectable as integration targets.
archivedAt: null,
},
select: selectSurvey,
orderBy: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;

// Reset is hidden client-side while archived, but re-check server-side so a stale tab or a direct
// action call can't wipe the responses/displays the 30-day archive window exists to preserve.
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
if (survey.archivedAt) {
throw new OperationNotAllowedError("Cannot reset an archived survey.");
}

const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
Expand Down Expand Up @@ -169,6 +179,12 @@ export const generateExampleResponsesAction = authenticatedActionClient
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}

// Same stale-tab defense as the responseCount check below: generating examples is hidden while
// archived, but a direct call would still write responses/displays/a tag into an archived survey.
if (survey.archivedAt) {
throw new OperationNotAllowedError("Cannot generate example responses for an archived survey.");
}

const existingCount = await getResponseCountBySurveyId(parsedInput.surveyId);
if (existingCount > 0) {
throw new OperationNotAllowedError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export const SurveyAnalysisCTA = ({
icon: BellRing,
tooltip: t("workspace.surveys.summary.configure_alerts"),
onClick: () => router.push(`/account/settings/notifications`),
isVisible: !isReadOnly,
isVisible: !isReadOnly && !survey.archivedAt,
},
{
icon: Eye,
Expand All @@ -232,20 +232,20 @@ export const SurveyAnalysisCTA = ({
const previewUrl = await getPreviewUrl();
window.open(previewUrl, "_blank");
},
isVisible: survey.type === "link",
isVisible: survey.type === "link" && !survey.archivedAt,
},
{
icon: Wand2,
tooltip: exampleResponsesTooltip,
onClick: handleGenerateExampleResponses,
disabled: isGeneratingExamples || aiUnavailableReason !== null || responseCount > 0,
isVisible: !isReadOnly,
isVisible: !isReadOnly && !survey.archivedAt,
},
{
icon: ListRestart,
tooltip: t("workspace.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly,
isVisible: !isReadOnly && !survey.archivedAt,
},
{
icon: SquarePenIcon,
Expand All @@ -255,7 +255,8 @@ export const SurveyAnalysisCTA = ({
? setIsCautionDialogOpen(true)
: router.push(`/workspaces/${workspace?.id}/surveys/${survey.id}/edit`);
},
isVisible: !isReadOnly,
// Archived surveys are read-only; editing is blocked server-side, so hide the entry point too.
isVisible: !isReadOnly && !survey.archivedAt,
},
];

Expand All @@ -266,14 +267,16 @@ export const SurveyAnalysisCTA = ({
)}

<IconBar actions={iconActions} />
<Button
onClick={() => {
setModalState((prev) => ({ ...prev, share: true }));
}}>
{t("workspace.surveys.summary.share_survey")}
</Button>
{!survey.archivedAt && (
<Button
onClick={() => {
setModalState((prev) => ({ ...prev, share: true }));
}}>
{t("workspace.surveys.summary.share_survey")}
</Button>
)}

{user && (
{user && !survey.archivedAt && (
<ShareSurveyModal
survey={survey}
publicDomain={publicDomain}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { ArchiveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -46,11 +47,40 @@ export const SurveyStatusDropdown = () => {
}
};

// Archived surveys are read-only. Render the same status-select control (so it reads as a natural
// status) with "Archived" as the value, but disabled — the status can't change while archived.
if (survey.archivedAt) {
return (
<Select value="archived" disabled>
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<div className="rounded-full bg-slate-300 p-1">
<ArchiveIcon className="size-3 text-slate-600" />
</div>
<span className="ml-2 text-sm text-slate-700">{t("common.archived")}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="archived">
<div className="flex w-full items-center justify-center gap-2">
<div className="rounded-full bg-slate-300 p-1">
<ArchiveIcon className="size-3 text-slate-600" />
</div>
{t("common.archived")}
</div>
</SelectItem>
</SelectContent>
</Select>
);
}

return (
<>
{survey.status === "draft" ? (
<div className="flex items-center">
<p className="text-sm italic text-slate-600">{t("common.draft")}</p>
<p className="text-sm text-slate-600 italic">{t("common.draft")}</p>
</div>
) : (
<Select
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
where: {
type: "app",
status: "inProgress",
// Never ship archived surveys to SDK clients (belt-and-suspenders: archiving forces a
// survey out of "inProgress", so this can't currently match, but keep it explicit so an
// archived app survey can never be synced or triggered by a code action).
archivedAt: null,
},
orderBy: {
createdAt: "desc",
Expand Down
20 changes: 17 additions & 3 deletions apps/web/app/api/v1/management/responses/[responseId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/li
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";

type TUncheckedResponseUpdate = Record<string, unknown> & {
Expand Down Expand Up @@ -138,9 +139,22 @@ export const PUT = withV1ApiWrapper({
};
}

if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
if (
!validateClientFileUploads({
data: responseUpdate.data,
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
blocks: result.survey.blocks,
questions: result.survey.questions,
// Management callers replay stored responses whose file URLs may predate the scoped shape;
// accept those against a prefix this workspace owns (ENG-1981 review).
legacyOwnedStoragePrefixes: await getWorkspaceLegacyStoragePrefixes(result.survey.workspaceId),
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
response: responses.badRequestResponse(
"Invalid file upload response: each file URL must reference a file uploaded to this survey's file-upload element"
),
};
}

Expand Down
24 changes: 21 additions & 3 deletions apps/web/app/api/v1/management/responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses, getResponsesByWorkspaceIds } from "./lib/response";

export const GET = withV1ApiWrapper({
Expand Down Expand Up @@ -132,9 +133,26 @@ export const POST = withV1ApiWrapper({
};
}

if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
if (
!validateClientFileUploads({
data: responseInput.data,
// Survey-authoritative workspace id (validateSurvey already asserts it equals the
// request-body workspaceId); binds the file-upload scope check to the resolved survey.
workspaceId: surveyResult.survey.workspaceId,
surveyId: surveyResult.survey.id,
blocks: surveyResult.survey.blocks,
questions: surveyResult.survey.questions,
// Management callers replay stored responses whose file URLs may predate the scoped shape;
// accept those against a prefix this workspace owns (ENG-1981 review).
legacyOwnedStoragePrefixes: await getWorkspaceLegacyStoragePrefixes(
surveyResult.survey.workspaceId
),
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
response: responses.badRequestResponse(
"Invalid file upload response: each file URL must reference a file uploaded to this survey's file-upload element"
),
};
}

Expand Down
28 changes: 28 additions & 0 deletions apps/web/app/api/v3/surveys/[surveyId]/archive/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from "zod";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { archiveV3Survey } from "../../lib/operations";
import { ZV3EmptyQuery } from "../../schemas";

const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});

export const POST = withV3ApiWrapper({
auth: "both",
action: "archived",
targetType: "survey",
schemas: {
params: surveyParamsSchema,
// Single-survey endpoints locate the survey by its globally-unique id; reject stray query params.
query: ZV3EmptyQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
return await archiveV3Survey({
surveyId: parsedInput.params.surveyId,
authentication,
requestId,
instance,
auditLog,
});
},
});
28 changes: 28 additions & 0 deletions apps/web/app/api/v3/surveys/[surveyId]/restore/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from "zod";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { restoreV3Survey } from "../../lib/operations";
import { ZV3EmptyQuery } from "../../schemas";

const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});

export const POST = withV3ApiWrapper({
auth: "both",
action: "restored",
targetType: "survey",
schemas: {
params: surveyParamsSchema,
// Single-survey endpoints locate the survey by its globally-unique id; reject stray query params.
query: ZV3EmptyQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
return await restoreV3Survey({
surveyId: parsedInput.params.surveyId,
authentication,
requestId,
instance,
auditLog,
});
},
});
Loading
Loading