Show invited people the Rule, let them copy its cards for their community, and replace an expired invitation link.
This commit is contained in:
1 parent
727a92a9ac
commit
3c984e00c5
37 files changed
+1962
-19
No files matched your search
@@ -62,6 +62,10 @@ import {
|
||||
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
CREATE_FLOW_COPIED_QUERY,
|
||||
CREATE_FLOW_COPIED_VALUE,
|
||||
} from "../../../lib/create/invitePaths";
|
||||
import {
|
||||
clearPendingKeepRuleLogin,
|
||||
hasPendingKeepRuleLogin,
|
||||
@@ -220,6 +224,7 @@ function CreateFlowLayoutContent({
|
||||
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
||||
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
|
||||
useState(false);
|
||||
const [completedCopiedReady, setCompletedCopiedReady] = useState(false);
|
||||
type LeaveConfirmChoice = "leave" | "save" | "cancel";
|
||||
const leaveConfirmResolverRef = useRef<
|
||||
((choice: LeaveConfirmChoice) => void) | null
|
||||
@@ -548,9 +553,12 @@ function CreateFlowLayoutContent({
|
||||
if (currentStep !== "completed") {
|
||||
setCompletedFlowBanner(null);
|
||||
setCompletedHasPublishedRule(false);
|
||||
setCompletedCopiedReady(false);
|
||||
return;
|
||||
}
|
||||
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
|
||||
const last = readLastPublishedRule();
|
||||
setCompletedHasPublishedRule(Boolean(last));
|
||||
setCompletedCopiedReady(Boolean(last?.copiedFromTitle));
|
||||
}, [currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -913,7 +921,13 @@ function CreateFlowLayoutContent({
|
||||
onExit={(opts) => void handleExit(opts)}
|
||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||
className={`relative z-[210] shrink-0 ${
|
||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||
isCompletedStep
|
||||
? completedCopiedReady ||
|
||||
searchParams.get(CREATE_FLOW_COPIED_QUERY) ===
|
||||
CREATE_FLOW_COPIED_VALUE
|
||||
? "!bg-[var(--color-lavender-lavender150,#d1bdf3)]"
|
||||
: "!bg-[var(--color-teal-teal50,#c9fef9)]"
|
||||
: ""
|
||||
}`.trim()}
|
||||
/>
|
||||
{currentStep === "community-save" ? (
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||
} from "../../utils/flowSteps";
|
||||
import {
|
||||
CREATE_FLOW_COPIED_QUERY,
|
||||
CREATE_FLOW_COPIED_VALUE,
|
||||
} from "../../../../../lib/create/invitePaths";
|
||||
import { communityPossessive } from "../../../../../lib/create/ruleInviteCardMeta";
|
||||
|
||||
function emptyCompletedDocumentState(): {
|
||||
headerTitle: string;
|
||||
@@ -80,6 +85,11 @@ export function CompletedScreen() {
|
||||
const celebrateInUrl =
|
||||
searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) ===
|
||||
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE;
|
||||
const copiedInUrl =
|
||||
searchParams.get(CREATE_FLOW_COPIED_QUERY) === CREATE_FLOW_COPIED_VALUE;
|
||||
const storedForCopy = ruleIdParam ? null : readLastPublishedRule();
|
||||
const copiedFromTitle = storedForCopy?.copiedFromTitle;
|
||||
const copiedNameHit = storedForCopy?.copiedNameHit ?? null;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const m = useMessages();
|
||||
const completed = m.create.reviewAndComplete.completed;
|
||||
@@ -88,6 +98,7 @@ export function CompletedScreen() {
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
/** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */
|
||||
const [showCelebrateToast] = useState(celebrateInUrl);
|
||||
const [showCopiedReady] = useState(copiedInUrl);
|
||||
const [askOrganizerOpen, setAskOrganizerOpen] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
@@ -141,11 +152,17 @@ export function CompletedScreen() {
|
||||
}, [ruleIdParam, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!celebrateInUrl) return;
|
||||
if (!celebrateInUrl && !copiedInUrl) return;
|
||||
router.replace("/create/completed");
|
||||
}, [celebrateInUrl, router]);
|
||||
}, [celebrateInUrl, copiedInUrl, router]);
|
||||
|
||||
const toast = showCelebrateToast && !toastDismissed ? (
|
||||
const surfaceCopied = showCopiedReady || Boolean(copiedFromTitle);
|
||||
const surfaceClass = surfaceCopied
|
||||
? "bg-[var(--color-lavender-lavender150,#d1bdf3)]"
|
||||
: "bg-[var(--color-teal-teal50,#c9fef9)]";
|
||||
|
||||
const toast =
|
||||
!toastDismissed && (showCopiedReady || showCelebrateToast) ? (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
@@ -154,8 +171,19 @@ export function CompletedScreen() {
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={completed.toastTitle}
|
||||
description={completed.toastDescription}
|
||||
title={
|
||||
showCopiedReady
|
||||
? completed.copyReadyToastTitle
|
||||
: completed.toastTitle
|
||||
}
|
||||
description={
|
||||
showCopiedReady
|
||||
? completed.copyReadyToastDescription.replace(
|
||||
"{possessive}",
|
||||
communityPossessive(copiedFromTitle ?? ""),
|
||||
)
|
||||
: completed.toastDescription
|
||||
}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
@@ -166,7 +194,7 @@ export function CompletedScreen() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
||||
<div className={`flex min-h-0 w-full flex-1 flex-col overflow-hidden ${surfaceClass} md:h-full`}>
|
||||
<div
|
||||
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
@@ -180,7 +208,7 @@ export function CompletedScreen() {
|
||||
size="L"
|
||||
palette="inverse"
|
||||
/>
|
||||
{headerTitle ? (
|
||||
{headerTitle && !surfaceCopied ? (
|
||||
<p className="mt-[var(--space-400)] max-w-[640px] text-small-paragraph text-[var(--color-content-invert-secondary)]">
|
||||
{completed.askOrganizerLead}{" "}
|
||||
<InlineTextButton
|
||||
@@ -196,10 +224,33 @@ export function CompletedScreen() {
|
||||
className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
||||
className={`pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-0% via-50% to-transparent md:block ${
|
||||
surfaceCopied
|
||||
? "from-[var(--color-lavender-lavender150,#d1bdf3)]/55 via-[var(--color-lavender-lavender150,#d1bdf3)]/20"
|
||||
: "from-[var(--color-teal-teal50,#c9fef9)]/55 via-[var(--color-teal-teal50,#c9fef9)]/20"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="w-full min-w-0 py-0 md:pb-8">
|
||||
{copiedNameHit && copiedFromTitle ? (
|
||||
<div className="mb-[var(--space-400)]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="warning"
|
||||
title={completed.copyNameHitTitle.replace(
|
||||
"{name}",
|
||||
copiedFromTitle,
|
||||
)}
|
||||
description={completed.copyNameHitDescription
|
||||
.replace("{entry}", copiedNameHit.entryTitle)
|
||||
.replace("{category}", copiedNameHit.categoryName)}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
hasTrailingIcon={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CommunityRule
|
||||
sections={documentSections}
|
||||
useCardStyle={!mdUp}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSessionUser } from "../../../../lib/server/session";
|
||||
import { getAcceptedStakeholderInviteView } from "../../../../lib/server/publishedRules";
|
||||
import { inviteRuleCountsSpoken } from "../../../../lib/create/ruleInviteCardMeta";
|
||||
import {
|
||||
INVITE_COPY_VALUE,
|
||||
invitedRulePath,
|
||||
} from "../../../../lib/create/invitePaths";
|
||||
import { InvitedRuleView } from "../_components/InvitedRuleView";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ ruleId: string }>;
|
||||
searchParams: Promise<{ copy?: string }>;
|
||||
};
|
||||
|
||||
export default async function InviteRulePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { ruleId } = await params;
|
||||
const query = await searchParams;
|
||||
const copy = query.copy === INVITE_COPY_VALUE;
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
const next = invitedRulePath(ruleId, { copy });
|
||||
redirect(`/login?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
const view = await getAcceptedStakeholderInviteView({
|
||||
userId: user.id,
|
||||
ruleId,
|
||||
});
|
||||
if (!view) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<InvitedRuleView
|
||||
ruleId={view.ruleId}
|
||||
title={view.title}
|
||||
summary={view.summary}
|
||||
document={view.document}
|
||||
updatedAt={view.updatedAt}
|
||||
inviterEmail={view.inviterEmail}
|
||||
viewerEmail={user.email}
|
||||
countsSpoken={inviteRuleCountsSpoken(view.document)}
|
||||
openCopyDialog={copy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Login from "../../../components/modals/Login";
|
||||
import Button from "../../../components/buttons/Button";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import ContentLockup from "../../../components/type/ContentLockup";
|
||||
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
|
||||
import { INVITE_EMAIL_PATTERN } from "../../../../lib/create/parseInviteEmails";
|
||||
import { resendExpiredStakeholderInvite } from "../../../../lib/create/api";
|
||||
|
||||
const expiredPageBgClass =
|
||||
"min-h-[100dvh] bg-[var(--color-surface-invert-brand-primary)]";
|
||||
|
||||
export function ExpiredInviteScreen() {
|
||||
const t = useTranslation("pages.invite");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
const titleId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const heading = sent
|
||||
? { title: t("sentTitle"), description: t("sentBody") }
|
||||
: { title: t("expiredTitle"), description: t("expiredDescription") };
|
||||
|
||||
async function sendLink() {
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
if (!INVITE_EMAIL_PATTERN.test(trimmed)) {
|
||||
setEmailError(t("emailInvalid"));
|
||||
return;
|
||||
}
|
||||
if (token.length < 10) {
|
||||
setFormError(t("missingToken"));
|
||||
return;
|
||||
}
|
||||
setEmailError("");
|
||||
setFormError("");
|
||||
setSubmitting(true);
|
||||
const result = await resendExpiredStakeholderInvite(token, trimmed);
|
||||
setSubmitting(false);
|
||||
if (result.ok === false) {
|
||||
if (result.retryAfterMs) {
|
||||
const seconds = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
|
||||
setFormError(t("rateLimited").replace("{seconds}", String(seconds)));
|
||||
return;
|
||||
}
|
||||
setFormError(result.error || t("genericError"));
|
||||
return;
|
||||
}
|
||||
setSent(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={expiredPageBgClass}>
|
||||
<Login
|
||||
isOpen
|
||||
usePortal={false}
|
||||
backdropVariant="solid"
|
||||
onClose={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
ariaLabelledBy={titleId}
|
||||
belowCard={
|
||||
<Link
|
||||
href="/"
|
||||
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||
>
|
||||
{t("backToHome")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ContentLockup
|
||||
titleId={titleId}
|
||||
title={heading.title}
|
||||
description={heading.description}
|
||||
variant="login"
|
||||
alignment="left"
|
||||
/>
|
||||
{formError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
{!sent ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendLink();
|
||||
}}
|
||||
noValidate
|
||||
>
|
||||
<TextInput
|
||||
label={t("emailLabel")}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
setEmailError("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
error={Boolean(emailError)}
|
||||
showHelpIcon
|
||||
maxLength={EMAIL_MAX_LEN}
|
||||
/>
|
||||
{emailError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-[14px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{t("notInvitedAddress")}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
disabled={submitting}
|
||||
className="w-full !justify-center text-center px-[var(--space-400)] py-[var(--space-300)]"
|
||||
>
|
||||
{t("sendNewLink")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</Login>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Logo from "../../../components/asset/Logo";
|
||||
import Button from "../../../components/buttons/Button";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
import CommunityRule from "../../../components/type/CommunityRule";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { makeCopyPublishedRule } from "../../../../lib/create/api";
|
||||
import { communityPossessive } from "../../../../lib/create/ruleInviteCardMeta";
|
||||
import { CREATE_FLOW_COPIED_QUERY, CREATE_FLOW_COPIED_VALUE } from "../../../../lib/create/invitePaths";
|
||||
import { formatInviteLastChanged } from "../../../../lib/create/inviteLastChanged";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import { downloadStoredRuleAsPdf } from "../../../../lib/create/ruleExport";
|
||||
import { createCompletedPath } from "../../create/utils/createFlowPaths";
|
||||
import { CreateFlowHeaderLockup } from "../../create/components/CreateFlowHeaderLockup";
|
||||
import { useCreateFlowMdUp } from "../../create/hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
||||
} from "../../create/components/createFlowLayoutTokens";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||
import { MakeACopyDialog } from "./MakeACopyDialog";
|
||||
import type { CommunityRuleSection } from "../../../components/type/CommunityRule/CommunityRule.types";
|
||||
|
||||
const outlineButtonClass =
|
||||
"md:!text-x-small-label !text-xx-small-label !px-[var(--space-150)] md:!px-[var(--space-200)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
|
||||
|
||||
const SURFACE = "bg-[var(--color-teal-teal50,#c9fef9)]";
|
||||
|
||||
export type InvitedRuleViewProps = {
|
||||
ruleId: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
document: Record<string, unknown>;
|
||||
updatedAt: string;
|
||||
inviterEmail: string | null;
|
||||
viewerEmail: string;
|
||||
countsSpoken: string;
|
||||
openCopyDialog?: boolean;
|
||||
};
|
||||
|
||||
export function InvitedRuleView({
|
||||
ruleId,
|
||||
title,
|
||||
summary,
|
||||
document,
|
||||
updatedAt,
|
||||
inviterEmail,
|
||||
viewerEmail,
|
||||
countsSpoken,
|
||||
openCopyDialog = false,
|
||||
}: InvitedRuleViewProps) {
|
||||
const t = useTranslation("pages.invite");
|
||||
const router = useRouter();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const [copyOpen, setCopyOpen] = useState(openCopyDialog);
|
||||
const [copyBusy, setCopyBusy] = useState(false);
|
||||
const [copyError, setCopyError] = useState<string | null>(null);
|
||||
const [exportError, setExportError] = useState(false);
|
||||
|
||||
const sections: CommunityRuleSection[] =
|
||||
parsePublishedDocumentForCommunityRuleDisplay(document);
|
||||
const possessive = communityPossessive(title);
|
||||
const lastChanged = formatInviteLastChanged(updatedAt);
|
||||
const bannerTitle = inviterEmail
|
||||
? t("bannerTitle").replace("{email}", inviterEmail)
|
||||
: t("bannerTitleNoEmail");
|
||||
const whoCanEdit = inviterEmail ?? t("whoCanEditUnknown");
|
||||
|
||||
useEffect(() => {
|
||||
if (!openCopyDialog) return;
|
||||
router.replace(`/invite/${encodeURIComponent(ruleId)}`);
|
||||
}, [openCopyDialog, ruleId, router]);
|
||||
|
||||
const handlePdf = useCallback(() => {
|
||||
try {
|
||||
downloadStoredRuleAsPdf({
|
||||
id: ruleId,
|
||||
title,
|
||||
summary,
|
||||
document,
|
||||
});
|
||||
setExportError(false);
|
||||
} catch {
|
||||
setExportError(true);
|
||||
}
|
||||
}, [document, ruleId, summary, title]);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (communityName: string) => {
|
||||
setCopyBusy(true);
|
||||
setCopyError(null);
|
||||
const result = await makeCopyPublishedRule(ruleId, communityName);
|
||||
setCopyBusy(false);
|
||||
if (result.ok === false) {
|
||||
setCopyError(t("copyFailed"));
|
||||
return;
|
||||
}
|
||||
writeLastPublishedRule({
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
summary: result.summary,
|
||||
document: result.document,
|
||||
copiedFromTitle: result.copiedFromTitle,
|
||||
copiedNameHit: result.copiedNameHit,
|
||||
});
|
||||
router.push(
|
||||
createCompletedPath({
|
||||
[CREATE_FLOW_COPIED_QUERY]: CREATE_FLOW_COPIED_VALUE,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[ruleId, router, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} ${SURFACE}`}>
|
||||
<header
|
||||
className={`${SURFACE} w-full print:hidden`}
|
||||
role="banner"
|
||||
>
|
||||
<nav
|
||||
className={`mx-auto flex w-full max-w-[1920px] items-center justify-between ${CREATE_FLOW_PAGE_GUTTER_CLASS} py-[var(--space-300)] md:py-[var(--space-400)]`}
|
||||
aria-label={t("navAria")}
|
||||
>
|
||||
<Logo size="createFlow" wordmark palette="inverse" />
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="inverse"
|
||||
size="xsmall"
|
||||
type="button"
|
||||
ariaLabel={t("makeACopyAria")}
|
||||
className={outlineButtonClass}
|
||||
onClick={() => {
|
||||
setCopyError(null);
|
||||
setCopyOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("makeACopy")}
|
||||
</Button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:h-full max-md:grid-rows-[auto_minmax(0,1fr)] max-md:overflow-hidden max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--space-1200)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`flex shrink-0 flex-col justify-start min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={title}
|
||||
description={summary ?? undefined}
|
||||
justification="left"
|
||||
size="L"
|
||||
palette="inverse"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`relative flex min-h-0 flex-col overflow-x-hidden overflow-y-auto md:max-h-full md:self-stretch ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col gap-[var(--space-600)] py-0 md:pb-8">
|
||||
<div className="print:hidden">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="default"
|
||||
title={bannerTitle}
|
||||
description={t("bannerDescription")}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
hasTrailingIcon={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[var(--space-300)] print:hidden">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="inverse"
|
||||
size="xsmall"
|
||||
type="button"
|
||||
className={outlineButtonClass}
|
||||
onClick={handlePdf}
|
||||
>
|
||||
{t("downloadPdf")}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="inverse"
|
||||
size="xsmall"
|
||||
type="button"
|
||||
className={outlineButtonClass}
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
{t("print")}
|
||||
</Button>
|
||||
</div>
|
||||
{exportError ? (
|
||||
<p role="alert" className="text-small-paragraph print:hidden">
|
||||
{t("exportFailedDescription")}
|
||||
</p>
|
||||
) : null}
|
||||
<CommunityRule
|
||||
sections={sections}
|
||||
useCardStyle={!mdUp}
|
||||
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
|
||||
/>
|
||||
<div className="flex w-full flex-col border-t border-[var(--color-content-invert-secondary)] print:hidden">
|
||||
<h2 className="sr-only">{t("factsHeading")}</h2>
|
||||
<FactRow label={t("whoCanEdit")} value={whoCanEdit} />
|
||||
<FactRow label={t("whoCanSee")} value={t("whoCanSeeValue")} />
|
||||
<FactRow label={t("lastChanged")} value={lastChanged} />
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-col gap-[var(--space-400)] rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-600)] text-[var(--color-content-invert-primary)] print:hidden">
|
||||
<p className="font-bricolage text-x-small-heading">
|
||||
{t("copyCardTitle")}
|
||||
</p>
|
||||
<p className="text-small-paragraph">
|
||||
{t("copyCardBody").replace("{counts}", countsSpoken)}
|
||||
</p>
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="inverse"
|
||||
size="small"
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
setCopyError(null);
|
||||
setCopyOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("makeACopy")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-small-paragraph text-[var(--color-content-invert-secondary)] print:hidden">
|
||||
{t("privacy")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MakeACopyDialog
|
||||
isOpen={copyOpen}
|
||||
onClose={() => {
|
||||
if (!copyBusy) setCopyOpen(false);
|
||||
}}
|
||||
possessive={possessive}
|
||||
countsSpoken={countsSpoken}
|
||||
viewerEmail={viewerEmail}
|
||||
busy={copyBusy}
|
||||
error={copyError}
|
||||
onConfirm={(communityName) => {
|
||||
void handleCopy(communityName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-[var(--space-400)] border-b border-[var(--color-content-invert-secondary)] py-[var(--space-300)]">
|
||||
<p className="text-small-label text-[var(--color-content-invert-secondary)]">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-right text-small-paragraph text-[var(--color-content-invert-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Button from "../../../components/buttons/Button";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import Dialog from "../../../components/modals/Dialog";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
|
||||
const COMMUNITY_NAME_MAX = 48;
|
||||
|
||||
export function MakeACopyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
possessive,
|
||||
countsSpoken,
|
||||
viewerEmail,
|
||||
busy,
|
||||
error,
|
||||
onConfirm,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
possessive: string;
|
||||
countsSpoken: string;
|
||||
viewerEmail: string;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onConfirm: (_title: string) => void;
|
||||
}) {
|
||||
const t = useTranslation("pages.invite");
|
||||
const [title, setTitle] = useState("");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const trimmed = title.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
title={t("dialogTitle")}
|
||||
description={t("dialogDescription")
|
||||
.replace("{possessive}", possessive)
|
||||
.replace("{counts}", countsSpoken)}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (trimmed.length === 0) {
|
||||
setNameError(t("nameRequired"));
|
||||
return;
|
||||
}
|
||||
onConfirm(trimmed);
|
||||
}}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col gap-[var(--space-500)]">
|
||||
<TextInput
|
||||
id="make-copy-community-name"
|
||||
inputSize="small"
|
||||
showHelpIcon={false}
|
||||
label={t("communityNameLabel")}
|
||||
placeholder={t("communityNamePlaceholder")}
|
||||
value={title}
|
||||
maxLength={COMMUNITY_NAME_MAX}
|
||||
error={Boolean(nameError)}
|
||||
textHint={
|
||||
nameError ||
|
||||
t("characterCountTemplate")
|
||||
.replace("{current}", String(title.length))
|
||||
.replace("{max}", String(COMMUNITY_NAME_MAX))
|
||||
}
|
||||
onChange={(event) => {
|
||||
setTitle(event.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
/>
|
||||
<div className="grid w-full grid-cols-1 gap-[var(--space-300)] sm:grid-cols-2">
|
||||
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
|
||||
<p className="text-small-label">{t("comesWithTitle")}</p>
|
||||
<p className="mt-[var(--space-200)] text-small-paragraph">
|
||||
{t("comesWithBody")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-400)] bg-[var(--color-surface-invert-brand-primary)] p-[var(--space-500)] text-[var(--color-content-invert-primary)]">
|
||||
<p className="text-small-label">{t("staysWithTitle")}</p>
|
||||
<p className="mt-[var(--space-200)] text-small-paragraph">
|
||||
{t("staysWithBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{t("saveTo").replace("{email}", viewerEmail)}
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/invite/expired", {
|
||||
title: messages.metadata.inviteExpired.title,
|
||||
description: messages.metadata.inviteExpired.description,
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
});
|
||||
|
||||
export default function ExpiredInviteLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { ExpiredInviteScreen } from "../_components/ExpiredInviteScreen";
|
||||
|
||||
function ExpiredInviteFallback() {
|
||||
const t = useTranslation("pages.invite");
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center bg-[var(--color-surface-invert-brand-primary)]">
|
||||
<p className="text-[14px] text-[var(--color-content-default-primary)]">
|
||||
{t("loadingFallback")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExpiredInvitePage() {
|
||||
return (
|
||||
<Suspense fallback={<ExpiredInviteFallback />}>
|
||||
<ExpiredInviteScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/invite", {
|
||||
title: messages.metadata.invite.title,
|
||||
description: messages.metadata.invite.description,
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
});
|
||||
|
||||
export default function InviteLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -345,7 +345,7 @@ export function ProfilePageView({
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
href: `/invite/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashSessionToken, newSessionToken } from "../../../../../lib/server/hash";
|
||||
import { sendRuleStakeholderInviteEmail } from "../../../../../lib/server/mail";
|
||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||
import { stakeholderInviteVerifyUrl } from "../../../../../lib/server/ruleStakeholderInviteOps";
|
||||
import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../lib/server/ruleStakeholders";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
} from "../../../../../lib/server/responses";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
||||
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||
import { resendExpiredStakeholderInviteBodySchema } from "../../../../../lib/server/validation/createFlowSchemas";
|
||||
|
||||
function clientIp(request: NextRequest): string {
|
||||
return (
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiRoute(
|
||||
"invites.ruleStakeholder.resendExpired",
|
||||
async (request: NextRequest, _ctx, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const body = await readLimitedJson(request);
|
||||
if (body.ok === false) return body.response;
|
||||
const parsed = resendExpiredStakeholderInviteBodySchema.safeParse(body.value);
|
||||
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||
|
||||
const { token, email } = parsed.data;
|
||||
const rl = rateLimitKey(
|
||||
`invite-resend-expired:${clientIp(request)}:${email}`,
|
||||
60_000,
|
||||
);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, {
|
||||
phase: "getSessionPepper",
|
||||
});
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const row = await prisma.ruleStakeholder.findUnique({
|
||||
where: { inviteTokenHash: tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
inviteTokenHash: true,
|
||||
inviteExpiresAt: true,
|
||||
invitedBy: { select: { email: true } },
|
||||
rule: {
|
||||
select: {
|
||||
title: true,
|
||||
summary: true,
|
||||
document: true,
|
||||
user: { select: { email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!row || row.email !== email) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const prevHash = row.inviteTokenHash;
|
||||
const prevExp = row.inviteExpiresAt;
|
||||
const nextToken = newSessionToken();
|
||||
const newHash = hashSessionToken(nextToken, pepper);
|
||||
const newExp = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS);
|
||||
|
||||
await prisma.ruleStakeholder.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
inviteTokenHash: newHash,
|
||||
inviteExpiresAt: newExp,
|
||||
},
|
||||
});
|
||||
|
||||
const origin = getPublicOrigin(request);
|
||||
const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken);
|
||||
const inviterEmail =
|
||||
row.invitedBy?.email ?? row.rule.user?.email ?? "CommunityRule";
|
||||
try {
|
||||
await sendRuleStakeholderInviteEmail({
|
||||
to: row.email,
|
||||
verifyUrl,
|
||||
origin,
|
||||
communityName: row.rule.title,
|
||||
inviterEmail,
|
||||
summary: row.rule.summary,
|
||||
document: row.rule.document,
|
||||
});
|
||||
} catch (err) {
|
||||
logRouteError("invites.ruleStakeholder.resendExpired", requestId, err, {
|
||||
phase: "sendRuleStakeholderInviteEmail",
|
||||
});
|
||||
await prisma.ruleStakeholder
|
||||
.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
inviteTokenHash: prevHash,
|
||||
inviteExpiresAt: prevExp,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
return errorJson(
|
||||
"mail_failed",
|
||||
"Could not resend stakeholder invite",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
import { getPublicOrigin } from "../../../../../lib/server/publicOrigin";
|
||||
import {
|
||||
INVITE_COPY_QUERY,
|
||||
INVITE_COPY_VALUE,
|
||||
inviteExpiredPath,
|
||||
invitedRulePath,
|
||||
} from "../../../../../lib/create/invitePaths";
|
||||
|
||||
const SCOPE = "invites.ruleStakeholder.verify";
|
||||
|
||||
@@ -66,7 +72,7 @@ export async function GET(request: NextRequest) {
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/login?error=expired_link",
|
||||
inviteExpiredPath(token),
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
@@ -104,7 +110,9 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
|
||||
const dest = `/rules/${encodeURIComponent(row.ruleId)}`;
|
||||
const copy =
|
||||
request.nextUrl.searchParams.get(INVITE_COPY_QUERY) === INVITE_COPY_VALUE;
|
||||
const dest = invitedRulePath(row.ruleId, { copy });
|
||||
return redirectWithRequestId(request, dest, requestId);
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../../../lib/server/env";
|
||||
import {
|
||||
dbUnavailable,
|
||||
forbidden,
|
||||
notFound,
|
||||
rateLimited,
|
||||
unauthorized,
|
||||
} from "../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../lib/server/session";
|
||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
||||
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||
import {
|
||||
makeCopyRuleBodySchema,
|
||||
publishedRuleDocumentSchema,
|
||||
} from "../../../../../lib/server/validation/createFlowSchemas";
|
||||
import {
|
||||
findCopiedNameHit,
|
||||
stripCopiedRuleDocument,
|
||||
} from "../../../../../lib/create/copiedRuleDocument";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiRoute<RouteContext>(
|
||||
"rules.byId.makeCopy",
|
||||
async (request, context) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const body = await readLimitedJson(request);
|
||||
if (body.ok === false) return body.response;
|
||||
const parsed = makeCopyRuleBodySchema.safeParse(body.value);
|
||||
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||
|
||||
const rl = rateLimitKey(`rule-make-copy:${user.id}:${id}`, 60_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
const stakeholder = await prisma.ruleStakeholder.findFirst({
|
||||
where: { ruleId: id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
rule: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
document: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!stakeholder) {
|
||||
return notFound();
|
||||
}
|
||||
if (stakeholder.rule.userId === user.id) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const stripped = stripCopiedRuleDocument(stakeholder.rule.document);
|
||||
const documentParsed = publishedRuleDocumentSchema.safeParse(stripped);
|
||||
if (!documentParsed.success) {
|
||||
return jsonFromZodError(documentParsed.error);
|
||||
}
|
||||
|
||||
const copiedNameHit = findCopiedNameHit(
|
||||
documentParsed.data,
|
||||
stakeholder.rule.title,
|
||||
);
|
||||
|
||||
const newRule = await prisma.publishedRule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: parsed.data.title,
|
||||
summary: null,
|
||||
document: documentParsed.data as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rule: {
|
||||
id: newRule.id,
|
||||
title: newRule.title,
|
||||
summary: newRule.summary,
|
||||
document: documentParsed.data,
|
||||
copiedFromTitle: stakeholder.rule.title,
|
||||
copiedNameHit,
|
||||
createdAt: newRule.createdAt,
|
||||
updatedAt: newRule.updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user