diff --git a/.env.example b/.env.example index e3cdfdf..513cd81 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ SESSION_SECRET="dev-only-change-me-16chars-min" SMTP_URL= SMTP_FROM="Community Rule " +# CR-107: inbox for Ask an organizer form submissions (requires SMTP_URL in production). +ORGANIZER_INQUIRY_TO= + # Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. NEXT_PUBLIC_ENABLE_BACKEND_SYNC= diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 642198f..729d37f 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -17,6 +17,9 @@ import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport import CreateFlowFooter from "../../components/navigation/CreateFlowFooter"; import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav"; import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, getNextStep, getStepIndex, parseReviewReturnSearchParam, @@ -158,7 +161,17 @@ function CreateFlowLayoutContent({ resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState, + markCreateFlowInteraction, } = useCreateFlow(); + const manageStakeholdersIntent = + searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; + const editingPublishedRuleIdTrimmed = + state.editingPublishedRuleId?.trim() ?? ""; + const isConfirmStakeholdersManagePublished = + currentStep === "confirm-stakeholders" && + manageStakeholdersIntent && + editingPublishedRuleIdTrimmed.length > 0; const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = @@ -411,6 +424,7 @@ function CreateFlowLayoutContent({ const isRightRailStep = currentStep === "decision-approaches"; const isFinalReviewLike = currentStep === "final-review" || currentStep === "edit-rule"; + const isEditRuleStep = currentStep === "edit-rule"; const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep); /** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */ const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( @@ -581,6 +595,7 @@ function CreateFlowLayoutContent({ hasShare={isCompletedStep} hasExport={isCompletedStep} hasEdit={isCompletedStep} + hasManageStakeholders={isEditRuleStep} saveDraftOnExit={saveDraftOnExit} onShare={ isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined @@ -601,6 +616,20 @@ function CreateFlowLayoutContent({ } : undefined } + onManageStakeholders={ + isEditRuleStep + ? () => { + markCreateFlowInteraction(); + router.push( + createFlowStepPath("confirm-stakeholders", { + [CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "edit-rule", + [CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]: + CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, + }), + ); + } + : undefined + } onExit={(opts) => void handleExit(opts)} buttonPalette={isCompletedStep ? "inverse" : undefined} className={`shrink-0 ${ @@ -615,7 +644,11 @@ function CreateFlowLayoutContent({ {!isCompletedStep && ( {footer[customRuleConfirmFooter.footerMessageKey]} + ) : isConfirmStakeholdersManagePublished ? ( + ) : nextStep || isFinalReviewLike ? ( + ) : null} + + + + ))} + + )} + +
+
+ { + setEmail(e.target.value); + setFieldError(""); + }} + error={Boolean(fieldError)} + textHint={fieldError || false} + autoComplete="email" + /> +
+ +
+ + ); +} diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index daaf124..e2a3a14 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -218,8 +218,11 @@ export interface CreateFlowState { currentStep?: CreateFlowStep; /** Section drafts; structure will tighten as steps persist real shapes. */ sections?: Record[]; - /** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */ - stakeholders?: Record[]; + /** + * Stakeholder invite emails (confirm-stakeholders step). Normalized on the server; + * invites are sent at first publish (`POST /api/rules`). + */ + stakeholderEmails?: string[]; /** Extra step-specific fields (must be JSON-serializable for server draft sync). */ [key: string]: unknown; } diff --git a/app/(app)/create/utils/createFlowPaths.ts b/app/(app)/create/utils/createFlowPaths.ts index 8d5ba0f..9e2e0e6 100644 --- a/app/(app)/create/utils/createFlowPaths.ts +++ b/app/(app)/create/utils/createFlowPaths.ts @@ -4,7 +4,10 @@ */ import type { CreateFlowStep } from "../types"; -import { CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./flowSteps"; +import { + CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, +} from "./flowSteps"; export const CREATE_ROUTES = { root: "/", @@ -59,7 +62,7 @@ export function createCompletedPath(query?: CreateFlowPathQuery): string { /** * Navigate back from a facet step to final-review / edit-rule, dropping - * `reviewReturn` from the current query while preserving other params. + * `reviewReturn` and `manageStakeholders` from the current query while preserving other params. */ export function createFlowStepPathAfterStrippingReviewReturn( step: CreateFlowStep, @@ -67,6 +70,7 @@ export function createFlowStepPathAfterStrippingReviewReturn( ): string { const params = new URLSearchParams(searchParams?.toString() ?? ""); params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY); + params.delete(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY); const query: CreateFlowPathQuery = {}; params.forEach((value, key) => { query[key] = value; diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index cbe40aa..10cbab9 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -188,6 +188,13 @@ export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const; /** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */ export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const; +/** + * `/create/confirm-stakeholders?manageStakeholders=1` — edit published rule invites (requires `state.editingPublishedRuleId`). + * Typically paired with `reviewReturn=edit-rule`. + */ +export const CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY = "manageStakeholders" as const; +export const CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE = "1" as const; + export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule"; export function parseReviewReturnSearchParam( diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index b485643..49e75e8 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -340,28 +340,38 @@ export function ProfilePageView({ expanded size={ruleCardSize} hasBottomLinks - bottomLinks={[ - { - id: "view", - label: t("viewPublic"), - href: `/rules/${encodeURIComponent(rule.id)}`, - }, - { - id: "manage", - label: t("manageRule"), - href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, - }, - { - id: "dup", - label: t("duplicate"), - onClick: () => onDuplicateRule(rule.id), - }, - { - id: "del", - label: t("deleteRule"), - onClick: () => onDeleteRule(rule.id), - }, - ]} + bottomLinks={ + rule.role === "stakeholder" + ? [ + { + id: "view", + label: t("viewPublic"), + href: `/rules/${encodeURIComponent(rule.id)}`, + }, + ] + : [ + { + id: "view", + label: t("viewPublic"), + href: `/rules/${encodeURIComponent(rule.id)}`, + }, + { + id: "manage", + label: t("manageRule"), + href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`, + }, + { + id: "dup", + label: t("duplicate"), + onClick: () => onDuplicateRule(rule.id), + }, + { + id: "del", + label: t("deleteRule"), + onClick: () => onDeleteRule(rule.id), + }, + ] + } communityInitials={ rule.title.trim().charAt(0).toUpperCase() || "·" } diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index a2a7c21..b1339c3 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -28,7 +28,6 @@ const askOrganizerData = { title: "Still have questions?", subtitle: "Get answers from an experienced organizer", buttonText: "Ask an organizer", - buttonHref: "#contact", }; interface PageProps { diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx index 1644679..49909ee 100644 --- a/app/(marketing)/learn/page.tsx +++ b/app/(marketing)/learn/page.tsx @@ -24,7 +24,6 @@ export default function LearnPage() { subtitle: t("pages.learn.askOrganizer.subtitle"), description: t("pages.learn.askOrganizer.description"), buttonText: t("pages.learn.askOrganizer.buttonText"), - buttonHref: t("pages.learn.askOrganizer.buttonHref"), variant: "centered" as const, }; diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index d6cbb64..c75d8af 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -87,7 +87,6 @@ export default function Page() { title: t("pages.home.askOrganizer.title"), subtitle: t("pages.home.askOrganizer.subtitle"), buttonText: t("pages.home.askOrganizer.buttonText"), - buttonHref: t("pages.home.askOrganizer.buttonHref"), }; return ( diff --git a/app/api/invites/rule-stakeholder/verify/route.ts b/app/api/invites/rule-stakeholder/verify/route.ts new file mode 100644 index 0000000..ed63b04 --- /dev/null +++ b/app/api/invites/rule-stakeholder/verify/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +import { hashSessionToken } from "../../../../../lib/server/hash"; +import { + REQUEST_ID_HEADER, + getOrCreateRequestId, + logRouteError, +} from "../../../../../lib/server/requestId"; +import { dbUnavailable } from "../../../../../lib/server/responses"; +import { + createSessionForUser, + getSessionUser, + setSessionCookie, +} from "../../../../../lib/server/session"; + +const SCOPE = "invites.ruleStakeholder.verify"; + +export async function GET(request: NextRequest) { + const requestId = getOrCreateRequestId(request); + + if (!isDatabaseConfigured()) { + const res = dbUnavailable(); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; + } + + try { + const token = request.nextUrl.searchParams.get("token"); + if (!token || token.length < 10) { + return redirectWithRequestId( + request, + "/login?error=invalid_link", + requestId, + ); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" }); + return redirectWithRequestId(request, "/login?error=server", requestId); + } + + const tokenHash = hashSessionToken(token, pepper); + + const row = await prisma.ruleStakeholder.findUnique({ + where: { inviteTokenHash: tokenHash }, + select: { + id: true, + email: true, + ruleId: true, + inviteExpiresAt: true, + }, + }); + + if ( + !row || + !row.inviteExpiresAt || + row.inviteExpiresAt < new Date() + ) { + return redirectWithRequestId( + request, + "/login?error=expired_link", + requestId, + ); + } + + const existingSession = await getSessionUser(); + if ( + existingSession && + existingSession.email.trim().toLowerCase() !== row.email + ) { + return redirectWithRequestId( + request, + "/login?error=stakeholder_wrong_account", + requestId, + ); + } + + const user = await prisma.user.upsert({ + where: { email: row.email }, + create: { email: row.email }, + update: {}, + }); + + await prisma.ruleStakeholder.update({ + where: { id: row.id }, + data: { + userId: user.id, + acceptedAt: new Date(), + inviteTokenHash: null, + inviteExpiresAt: null, + }, + }); + + const { token: sessionToken, expiresAt } = await createSessionForUser( + user.id, + ); + await setSessionCookie(sessionToken, expiresAt); + + const dest = `/rules/${encodeURIComponent(row.ruleId)}`; + return redirectWithRequestId(request, dest, requestId); + } catch (err) { + logRouteError(SCOPE, requestId, err); + return redirectWithRequestId(request, "/login?error=server", requestId); + } +} + +function redirectWithRequestId( + request: NextRequest, + path: string, + requestId: string, +): NextResponse { + const res = NextResponse.redirect(new URL(path, request.url)); + res.headers.set(REQUEST_ID_HEADER, requestId); + return res; +} diff --git a/app/api/organizer-inquiry/route.ts b/app/api/organizer-inquiry/route.ts new file mode 100644 index 0000000..44632bc --- /dev/null +++ b/app/api/organizer-inquiry/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sendOrganizerInquiryNotification } from "../../../lib/server/mail"; +import { rateLimitKey } from "../../../lib/server/rateLimit"; +import { + errorJson, + rateLimited, + serverMisconfigured, +} from "../../../lib/server/responses"; +import { logRouteError } from "../../../lib/server/requestId"; +import { apiRoute } from "../../../lib/server/apiRoute"; +import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../lib/organizerInquiryConstants"; +import { organizerInquiryBodySchema } from "../../../lib/server/validation/organizerInquirySchemas"; +import { readLimitedJson } from "../../../lib/server/validation/requestBody"; +import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; + +const SCOPE = "organizer-inquiry.submit"; +const EMAIL_MIN_INTERVAL_MS = 60 * 1000; +const IP_MIN_INTERVAL_MS = 20 * 1000; + +function clientIp(request: NextRequest): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown" + ); +} + +function organizerInquiryTo(): string | null { + const raw = process.env.ORGANIZER_INQUIRY_TO?.trim(); + return raw && raw.length > 0 ? raw : null; +} + +export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => { + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + const validated = organizerInquiryBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } + + const { email, message } = validated.data; + const honeypot = validated.data[ORGANIZER_INQUIRY_HONEYPOT_FIELD]; + + if (honeypot.length > 0) { + // Silent success for bots — do not send mail or reveal rejection. + return NextResponse.json({ ok: true }); + } + + const ip = clientIp(request); + + const rlEmail = rateLimitKey(`organizer-inquiry-email:${email}`, EMAIL_MIN_INTERVAL_MS); + if (rlEmail.ok === false) { + return rateLimited(rlEmail.retryAfterMs); + } + + const rlIp = rateLimitKey(`organizer-inquiry-ip:${ip}`, IP_MIN_INTERVAL_MS); + if (rlIp.ok === false) { + return rateLimited(rlIp.retryAfterMs); + } + + const to = organizerInquiryTo(); + if (!to) { + return serverMisconfigured("ORGANIZER_INQUIRY_TO is not configured"); + } + + const from = process.env.SMTP_FROM ?? "noreply@localhost"; + + try { + await sendOrganizerInquiryNotification({ + to, + fromEmail: from, + visitorEmail: email, + message, + requestId, + }); + } catch (err) { + logRouteError(SCOPE, requestId, err, { phase: "sendOrganizerInquiryNotification" }); + return errorJson( + "mail_failed", + "We could not send your message. Please try again later.", + 502, + ); + } + + return NextResponse.json({ ok: true }); +}); diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts new file mode 100644 index 0000000..87fcc3e --- /dev/null +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts @@ -0,0 +1,120 @@ +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, + forbidden, + notFound, + rateLimited, + serverMisconfigured, + unauthorized, +} from "../../../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../../../lib/server/session"; +import { rateLimitKey } from "../../../../../../../lib/server/rateLimit"; + +type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> }; + +export const POST = apiRoute( + "rules.stakeholders.resend", + async (request: NextRequest, context, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId, stakeholderId } = await context.params; + + const row = await prisma.ruleStakeholder.findFirst({ + where: { id: stakeholderId, ruleId }, + select: { + id: true, + email: true, + inviteTokenHash: true, + inviteExpiresAt: true, + rule: { select: { userId: true, title: true } }, + }, + }); + + if (!row) { + return notFound(); + } + if (row.rule.userId !== user.id) { + return forbidden(); + } + if (row.inviteTokenHash === null) { + return errorJson( + "validation_error", + "This stakeholder has already accepted the invite", + 400, + ); + } + + const rl = rateLimitKey(`rule-stakeholders-resend:${row.id}`, 60_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError("rules.stakeholders.resend", requestId, err, { + phase: "getSessionPepper", + }); + return serverMisconfigured(); + } + + const prevHash = row.inviteTokenHash; + const prevExp = row.inviteExpiresAt; + const token = newSessionToken(); + const newHash = hashSessionToken(token, 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 verifyUrl = stakeholderInviteVerifyUrl(request.nextUrl.origin, token); + try { + await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title); + } catch (err) { + logRouteError("rules.stakeholders.resend", 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 }); + }, +); diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts new file mode 100644 index 0000000..51adcdd --- /dev/null +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../../lib/server/db"; +import { isDatabaseConfigured } from "../../../../../../lib/server/env"; +import { apiRoute } from "../../../../../../lib/server/apiRoute"; +import { + dbUnavailable, + forbidden, + notFound, + unauthorized, +} from "../../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../../lib/server/session"; + +type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> }; + +export const DELETE = apiRoute( + "rules.stakeholders.delete", + async (_request: NextRequest, context) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId, stakeholderId } = await context.params; + + const row = await prisma.ruleStakeholder.findFirst({ + where: { id: stakeholderId, ruleId }, + select: { + id: true, + rule: { select: { userId: true } }, + }, + }); + + if (!row) { + return notFound(); + } + if (row.rule.userId !== user.id) { + return forbidden(); + } + + await prisma.ruleStakeholder.delete({ where: { id: row.id } }); + + return NextResponse.json({ ok: true }); + }, +); diff --git a/app/api/rules/[id]/stakeholders/route.ts b/app/api/rules/[id]/stakeholders/route.ts new file mode 100644 index 0000000..f169cb2 --- /dev/null +++ b/app/api/rules/[id]/stakeholders/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/server/db"; +import { + getSessionPepper, + isDatabaseConfigured, +} from "../../../../../lib/server/env"; +import { rateLimitKey } from "../../../../../lib/server/rateLimit"; +import { apiRoute } from "../../../../../lib/server/apiRoute"; +import { logRouteError } from "../../../../../lib/server/requestId"; +import { createRuleStakeholderInviteAndSendMail } from "../../../../../lib/server/ruleStakeholderInviteOps"; +import { + conflict, + dbUnavailable, + errorJson, + notFound, + rateLimited, + serverMisconfigured, + unauthorized, +} from "../../../../../lib/server/responses"; +import { getSessionUser } from "../../../../../lib/server/session"; +import { + MAX_STAKEHOLDER_EMAILS, + postRuleStakeholderBodySchema, +} from "../../../../../lib/server/validation/createFlowSchemas"; +import { readLimitedJson } from "../../../../../lib/server/validation/requestBody"; +import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; + +type RouteContext = { params: Promise<{ id: string }> }; + +async function ownedRuleMeta(ruleId: string, userId: string) { + return prisma.publishedRule.findFirst({ + where: { id: ruleId, userId }, + select: { id: true, title: true }, + }); +} + +export const GET = apiRoute( + "rules.stakeholders.list", + async (_request, context) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId } = await context.params; + const rule = await ownedRuleMeta(ruleId, user.id); + if (!rule) { + return notFound(); + } + + const rows = await prisma.ruleStakeholder.findMany({ + where: { ruleId: rule.id }, + orderBy: [{ invitedAt: "asc" }, { id: "asc" }], + select: { + id: true, + email: true, + invitedAt: true, + acceptedAt: true, + inviteTokenHash: true, + }, + }); + + return NextResponse.json({ + stakeholders: rows.map((r) => ({ + id: r.id, + email: r.email, + invitedAt: r.invitedAt.toISOString(), + acceptedAt: r.acceptedAt?.toISOString() ?? null, + status: + r.inviteTokenHash !== null ? ("pending" as const) : ("accepted" as const), + })), + }); + }, +); + +export const POST = apiRoute( + "rules.stakeholders.add", + async (request: NextRequest, context, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + const { id: ruleId } = await context.params; + const rule = await ownedRuleMeta(ruleId, user.id); + if (!rule) { + return notFound(); + } + + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + const validated = postRuleStakeholderBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } + + const email = validated.data.email; + if (email === user.email.trim().toLowerCase()) { + return errorJson( + "validation_error", + "You cannot invite your own account email", + 400, + ); + } + + const existing = await prisma.ruleStakeholder.findFirst({ + where: { ruleId: rule.id, email }, + }); + if (existing) { + return conflict("That email is already invited for this rule"); + } + + const count = await prisma.ruleStakeholder.count({ + where: { ruleId: rule.id }, + }); + if (count >= MAX_STAKEHOLDER_EMAILS) { + return errorJson( + "validation_error", + `You can invite at most ${MAX_STAKEHOLDER_EMAILS} stakeholders per rule`, + 400, + ); + } + + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + const rl = rateLimitKey(`rule-stakeholders-add-ip:${ip}`, 60_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError("rules.stakeholders.add", requestId, err, { + phase: "getSessionPepper", + }); + return serverMisconfigured(); + } + + const origin = request.nextUrl.origin; + const sent = await createRuleStakeholderInviteAndSendMail({ + scope: "rules.stakeholders.add", + requestId, + origin, + ruleId: rule.id, + ruleTitle: rule.title, + email, + invitedByUserId: user.id, + pepper, + }); + + if (!sent.ok) { + return errorJson( + "mail_failed", + "Could not send stakeholder invite", + 502, + ); + } + + const created = await prisma.ruleStakeholder.findFirst({ + where: { ruleId: rule.id, email }, + select: { id: true, email: true, invitedAt: true }, + }); + + return NextResponse.json( + { + stakeholder: created && { + id: created.id, + email: created.email, + invitedAt: created.invitedAt.toISOString(), + acceptedAt: null, + status: "pending" as const, + }, + }, + { status: 201 }, + ); + }, +); diff --git a/app/api/rules/me/route.ts b/app/api/rules/me/route.ts index 69d5e8f..06a90d5 100644 --- a/app/api/rules/me/route.ts +++ b/app/api/rules/me/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { isDatabaseConfigured } from "../../../../lib/server/env"; -import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules"; +import { listProfileRulesForUser } from "../../../../lib/server/publishedRules"; import { dbUnavailable, internalError, @@ -22,10 +22,19 @@ export const GET = apiRoute("rules.me.list", async (request: NextRequest) => { const { searchParams } = new URL(request.url); const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100); - const rules = await listPublishedRulesForUser(user.id, take); + const rules = await listProfileRulesForUser(user.id, take); if (rules === null) { return internalError("Failed to list rules"); } - return NextResponse.json({ rules }); + return NextResponse.json({ + rules: rules.map((r) => ({ + id: r.id, + title: r.title, + summary: r.summary, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + role: r.role, + })), + }); }); diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 35ad450..2b0983b 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -1,14 +1,29 @@ import type { Prisma } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "../../../lib/server/db"; -import { isDatabaseConfigured } from "../../../lib/server/env"; +import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env"; +import { + hashSessionToken, + newSessionToken, +} from "../../../lib/server/hash"; +import { sendRuleStakeholderInviteEmail } from "../../../lib/server/mail"; +import { rateLimitKey } from "../../../lib/server/rateLimit"; import { dbUnavailable, + errorJson, + rateLimited, + serverMisconfigured, unauthorized, } from "../../../lib/server/responses"; +import { logRouteError } from "../../../lib/server/requestId"; +import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderInviteOps"; +import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders"; import { getSessionUser } from "../../../lib/server/session"; import { apiRoute } from "../../../lib/server/apiRoute"; -import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas"; +import { + publishRuleBodySchema, + uniqueStakeholderEmailsForPublish, +} from "../../../lib/server/validation/createFlowSchemas"; import { readLimitedJson } from "../../../lib/server/validation/requestBody"; import { jsonFromZodError } from "../../../lib/server/validation/zodHttp"; @@ -36,43 +51,134 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => { return NextResponse.json({ rules }); }); -export const POST = apiRoute("rules.publish", async (request: NextRequest) => { - if (!isDatabaseConfigured()) { - return dbUnavailable(); - } +export const POST = apiRoute( + "rules.publish", + async (request: NextRequest, _ctx, { requestId }) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } - const user = await getSessionUser(); - if (!user) { - return unauthorized(); - } + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } - const parsedBody = await readLimitedJson(request); - if (parsedBody.ok === false) { - return parsedBody.response; - } + const parsedBody = await readLimitedJson(request); + if (parsedBody.ok === false) { + return parsedBody.response; + } - const validated = publishRuleBodySchema.safeParse(parsedBody.value); - if (!validated.success) { - return jsonFromZodError(validated.error); - } + const validated = publishRuleBodySchema.safeParse(parsedBody.value); + if (!validated.success) { + return jsonFromZodError(validated.error); + } - const { title, summary, document } = validated.data; + const { title, summary, document, stakeholderEmails } = validated.data; + const inviteEmails = uniqueStakeholderEmailsForPublish( + stakeholderEmails, + user.email, + ); - const rule = await prisma.publishedRule.create({ - data: { - userId: user.id, - title, - summary, - document: document as Prisma.InputJsonValue, - }, - }); + if (inviteEmails.length > 0) { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; + const rl = rateLimitKey(`publish-stakeholders-ip:${ip}`, 60_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + } - return NextResponse.json({ - rule: { - id: rule.id, - title: rule.title, - summary: rule.summary, - createdAt: rule.createdAt, - }, - }); -}); + if (inviteEmails.length === 0) { + const rule = await prisma.publishedRule.create({ + data: { + userId: user.id, + title, + summary, + document: document as Prisma.InputJsonValue, + }, + }); + + return NextResponse.json({ + rule: { + id: rule.id, + title: rule.title, + summary: rule.summary, + createdAt: rule.createdAt, + }, + }); + } + + let pepper: string; + try { + pepper = getSessionPepper(); + } catch (err) { + logRouteError("rules.publish", requestId, err, { + phase: "getSessionPepper", + }); + return serverMisconfigured(); + } + + const expiresAt = new Date(Date.now() + STAKEHOLDER_INVITE_TTL_MS); + const { rule, invites } = await prisma.$transaction(async (tx) => { + const created = await tx.publishedRule.create({ + data: { + userId: user.id, + title, + summary, + document: document as Prisma.InputJsonValue, + }, + }); + const toSend: { email: string; token: string }[] = []; + for (const email of inviteEmails) { + const token = newSessionToken(); + const tokenHash = hashSessionToken(token, pepper); + await tx.ruleStakeholder.create({ + data: { + ruleId: created.id, + email, + invitedByUserId: user.id, + inviteTokenHash: tokenHash, + inviteExpiresAt: expiresAt, + }, + }); + toSend.push({ email, token }); + } + return { rule: created, invites: toSend }; + }); + + const origin = request.nextUrl.origin; + try { + for (const inv of invites) { + const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); + await sendRuleStakeholderInviteEmail(inv.email, verifyUrl, title); + } + } catch (err) { + logRouteError("rules.publish", requestId, err, { + phase: "sendRuleStakeholderInviteEmail", + }); + try { + await prisma.publishedRule.delete({ where: { id: rule.id } }); + } catch (delErr) { + logRouteError("rules.publish", requestId, delErr, { + phase: "rollbackPublishAfterMailFailure", + }); + } + return errorJson( + "mail_failed", + "Could not send stakeholder invites", + 502, + ); + } + + return NextResponse.json({ + rule: { + id: rule.id, + title: rule.title, + summary: rule.summary, + createdAt: rule.createdAt, + }, + }); + }, +); diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx new file mode 100644 index 0000000..b381a20 --- /dev/null +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.container.tsx @@ -0,0 +1,124 @@ +"use client"; + +/** + * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823) + * File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823 + */ + +import { memo, useCallback, useEffect, useState, type FormEvent } from "react"; +import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view"; +import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; +import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants"; +import { useTranslation } from "../../../contexts/MessagesContext"; + +const AskOrganizerInquiryModalContainer = memo( + ({ isOpen, onClose }) => { + const t = useTranslation("modals.askOrganizerInquiry"); + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [honeypot, setHoneypot] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [formError, setFormError] = useState(null); + const [emailError, setEmailError] = useState(false); + const [questionError, setQuestionError] = useState(false); + + useEffect(() => { + if (!isOpen) { + setEmail(""); + setMessage(""); + setHoneypot(""); + setSubmitting(false); + setSuccess(false); + setFormError(null); + setEmailError(false); + setQuestionError(false); + } + }, [isOpen]); + + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setFormError(null); + setEmailError(false); + setQuestionError(false); + setSubmitting(true); + + try { + const res = await fetch("/api/organizer-inquiry", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email, + message, + [ORGANIZER_INQUIRY_HONEYPOT_FIELD]: honeypot, + }), + }); + + const data: unknown = await res.json().catch(() => null); + + if (res.ok) { + setSuccess(true); + return; + } + + if (res.status === 429) { + setFormError(t("rateLimitedError")); + return; + } + + if ( + data && + typeof data === "object" && + "error" in data && + data.error && + typeof data.error === "object" && + "message" in data.error && + typeof (data.error as { message: unknown }).message === "string" + ) { + const msg = (data.error as { message: string }).message; + const lower = msg.toLowerCase(); + if (lower.includes("email")) { + setEmailError(true); + } + if (lower.includes("character") || lower.includes("question")) { + setQuestionError(true); + } + setFormError(msg); + return; + } + + setFormError(t("genericError")); + } catch { + setFormError(t("genericError")); + } finally { + setSubmitting(false); + } + }, + [email, message, honeypot, t], + ); + + return ( + + ); + }, +); + +AskOrganizerInquiryModalContainer.displayName = "AskOrganizerInquiryModal"; + +export default AskOrganizerInquiryModalContainer; diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts new file mode 100644 index 0000000..f6abe4e --- /dev/null +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.types.ts @@ -0,0 +1,4 @@ +export interface AskOrganizerInquiryModalProps { + isOpen: boolean; + onClose: () => void; +} diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx new file mode 100644 index 0000000..95c2d27 --- /dev/null +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx @@ -0,0 +1,164 @@ +"use client"; + +import type { FormEvent } from "react"; +import Create from "../Create"; +import TextInput from "../../controls/TextInput"; +import TextArea from "../../controls/TextArea"; +import Button from "../../buttons/Button"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { + ASK_ORGANIZER_INQUIRY_FORM_ID, + ORGANIZER_INQUIRY_HONEYPOT_FIELD, +} from "../../../../lib/organizerInquiryConstants"; +import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; + +export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & { + email: string; + message: string; + honeypot: string; + submitting: boolean; + success: boolean; + formError: string | null; + emailError: boolean; + questionError: boolean; + onEmailChange: (_v: string) => void; + onMessageChange: (_v: string) => void; + onHoneypotChange: (_v: string) => void; + onSubmit: (_e: FormEvent) => void; +}; + +/** + * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823) + */ +export function AskOrganizerInquiryModalView({ + isOpen, + onClose, + email, + message, + honeypot, + submitting, + success, + formError, + emailError, + questionError, + onEmailChange, + onMessageChange, + onHoneypotChange, + onSubmit, +}: AskOrganizerInquiryModalViewProps) { + const t = useTranslation("modals.askOrganizerInquiry"); + + const footer = success ? ( +
+ +
+ ) : ( +
+ +
+ ); + + return ( + + {success ? ( +
+

+ {t("successTitle")} +

+

+ {t("successDescription")} +

+
+ ) : ( +
+ {formError ? ( +

+ {formError} +

+ ) : null} + + onEmailChange(e.target.value)} + error={emailError} + inputSize="medium" + showHelpIcon={false} + /> + +