diff --git a/.env.example b/.env.example index 4e6ab5a..e3cdfdf 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,7 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC= # Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.). # NEXT_PUBLIC_RUM_DASHBOARD_URL= + +# Writable directory for `POST /api/uploads` (community photo + custom-method attachments). +# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example: +# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads" diff --git a/.gitignore b/.gitignore index 594818a..5c074bb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ npm-cache/ # testing /coverage +# Local user uploads (see UPLOAD_ROOT in .env.example) +/var/uploads + # Playwright /test-results/ /playwright-report/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97ab22d..77ee7b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,8 @@ deployment-pipeline work. | GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. | | POST | `/api/auth/logout` | Clear session. | | GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. | +| POST | `/api/uploads` | Authenticated multipart upload (create-flow images / PDFs); requires `UPLOAD_ROOT`. | +| GET | `/api/uploads/[id]` | Stream a previously uploaded file by opaque id (public read). | | GET / POST | `/api/rules` | List or publish rules. | | GET | `/api/templates` | List curated templates. Optional repeatable `facet.=` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. | | GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. | diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index d5618d7..bc36822 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -73,6 +73,7 @@ import { useAuthModal } from "../../contexts/AuthModalContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; +import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush"; import Alert from "../../components/modals/Alert"; import Share from "../../components/modals/Share"; import { @@ -561,6 +562,12 @@ function CreateFlowLayoutContent({ + + + setShareModalOpen(false)} diff --git a/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx b/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx new file mode 100644 index 0000000..4a2c983 --- /dev/null +++ b/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useCreateFlow } from "../context/CreateFlowContext"; +import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; +import { + clearPendingCommunityAvatarFile, + readPendingCommunityAvatarFile, +} from "../../../../lib/create/pendingCommunityAvatarUpload"; + +/** + * After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick) + * and writes `communityAvatarUrl` on success. + */ +export function CreateFlowPendingAvatarFlush({ + sessionUser, + sessionResolved, +}: { + sessionUser: { id: string; email: string } | null | undefined; + sessionResolved: boolean; +}) { + const { updateState } = useCreateFlow(); + /** One successful flush per signed-in user id (survives React StrictMode remounts). */ + const lastFlushedUserIdRef = useRef(null); + + useEffect(() => { + if (!sessionResolved || !sessionUser) return; + if (lastFlushedUserIdRef.current === sessionUser.id) return; + let cancelled = false; + + void (async () => { + const file = await readPendingCommunityAvatarFile(); + if (cancelled || !file) return; + try { + const { url } = await uploadCreateFlowFile(file, "communityAvatar"); + if (cancelled) return; + await clearPendingCommunityAvatarFile(); + updateState({ communityAvatarUrl: url }); + lastFlushedUserIdRef.current = sessionUser.id; + } catch { + // Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT. + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionResolved, sessionUser, updateState]); + + return null; +} diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx index 83efa3f..9955f82 100644 --- a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx @@ -10,8 +10,8 @@ * {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}. */ -import { memo, useCallback, useRef } from "react"; -import { useMessages } from "../../../contexts/MessagesContext"; +import { memo, useCallback, useRef, useState } from "react"; +import { useMessages, useTranslation } from "../../../contexts/MessagesContext"; import Chip from "../../../components/controls/Chip"; import IncrementerBlock from "../../../components/controls/IncrementerBlock"; import InlineTextButton from "../../../components/buttons/InlineTextButton"; @@ -20,6 +20,7 @@ import ApplicableScopeField from "./ApplicableScopeField"; import InputLabel from "../../../components/type/InputLabel"; import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; import ModalTextAreaField from "./ModalTextAreaField"; +import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; const TEXT_VALUE_MAX = 8000; @@ -55,7 +56,12 @@ function CustomMethodCardUploadBlockRow({ noFileChosen: string; }) { const uploadInputRef = useRef(null); + const tUpload = useTranslation("create.upload"); + const [busy, setBusy] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const displayName = block.fileName?.trim() ? block.fileName : noFileChosen; + const hasAsset = Boolean(block.assetUrl?.trim()); + const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen; return (
@@ -65,41 +71,81 @@ function CustomMethodCardUploadBlockRow({ size="s" palette="default" /> -

- {displayName} -

+ {!hasAsset ? ( +

+ {displayName} +

+ ) : null} + {hasAsset ? ( + // eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL + {previewAlt} + ) : null} { const file = e.target.files?.[0]; - const name = file?.name?.trim(); - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "upload" - ? { - ...b, - ...(name ? { fileName: name } : {}), - } - : b, - ), - ); e.target.value = ""; + if (!file) return; + setErrorMessage(null); + setBusy(true); + void (async () => { + try { + const { url } = await uploadCreateFlowFile( + file, + "customMethodAttachment", + ); + const name = file.name?.trim(); + patch( + mapBlockById(blocks, block.id, (b) => + b.kind === "upload" + ? { + ...b, + ...(name ? { fileName: name } : {}), + assetUrl: url, + } + : b, + ), + ); + } catch { + setErrorMessage(tUpload("errors.generic")); + } finally { + setBusy(false); + } + })(); }} /> uploadInputRef.current?.click()} + active={!busy} + hintText={busy ? tUpload("uploading") : uploadHint} + onClick={() => { + if (!busy) uploadInputRef.current?.click(); + }} /> - {block.fileName?.trim() ? ( + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + {block.fileName?.trim() || block.assetUrl?.trim() ? ( patch( mapBlockById(blocks, block.id, (b) => - b.kind === "upload" ? { ...b, fileName: undefined } : b, + b.kind === "upload" + ? { ...b, fileName: undefined, assetUrl: undefined } + : b, ), ) } @@ -220,15 +266,30 @@ function CustomMethodCardFieldBlocksSummaryComponent({ return (
{readOnly ? ( - {}} - disabled - /> +
+ + {block.assetUrl?.trim() ? ( + // eslint-disable-next-line @next/next/no-img-element + { + ) : ( +

+ {noFileChosen} +

+ )} +
) : ( ( - ({ isOpen, onClose, onFinalize }) => { + ({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => { const m = useMessages(); const t = useTranslation("common"); + const tUpload = useTranslation("create.upload"); const w = m.create.customRule.customMethodCardWizard; const copy = useMemo( @@ -23,10 +27,23 @@ const CustomMethodCardWizardContainer = memo( step1: w.steps["1"], step2: w.steps["2"], step3: w.steps["3"], + step3BlocksList: w.step3BlocksList, + fieldTypeLabels: { + text: w.addCustomField.fieldTypes.text, + badges: w.addCustomField.fieldTypes.badges, + upload: w.addCustomField.fieldTypes.upload, + proportion: w.addCustomField.fieldTypes.proportion, + }, footerFinalize: w.footer.finalize, fieldModals: w.fieldModals, }), - [w.fieldModals, w.footer.finalize, w.steps], + [ + w.addCustomField.fieldTypes, + w.fieldModals, + w.footer.finalize, + w.step3BlocksList, + w.steps, + ], ); const fieldBodiesCopy = useMemo( @@ -58,6 +75,13 @@ const CustomMethodCardWizardContainer = memo( const [uploadFileName, setUploadFileName] = useState( undefined, ); + const [uploadAssetUrl, setUploadAssetUrl] = useState( + undefined, + ); + const [uploadFieldBusy, setUploadFieldBusy] = useState(false); + const [uploadFieldError, setUploadFieldError] = useState( + null, + ); const [proportionBlockTitle, setProportionBlockTitle] = useState(""); const [proportionDefault, setProportionDefault] = useState(50); @@ -70,6 +94,9 @@ const CustomMethodCardWizardContainer = memo( setBadgeOptions([]); setUploadBlockTitle(""); setUploadFileName(undefined); + setUploadAssetUrl(undefined); + setUploadFieldBusy(false); + setUploadFieldError(null); setProportionBlockTitle(""); setProportionDefault(50); if (fileInputRef.current) { @@ -135,10 +162,14 @@ const CustomMethodCardWizardContainer = memo( } if (fieldTypeModal === "upload") { const t0 = uploadBlockTitle.trim(); - return ( + const titleOk = t0.length > 0 && - t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS - ); + t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; + if (!titleOk) return false; + if (onPersistCustomUploadFile) { + return Boolean(uploadAssetUrl?.trim()); + } + return true; } const t0 = proportionBlockTitle.trim(); return ( @@ -154,6 +185,8 @@ const CustomMethodCardWizardContainer = memo( proportionDefault, textBlockTitle, uploadBlockTitle, + uploadAssetUrl, + onPersistCustomUploadFile, ]); const headerTitle = @@ -213,13 +246,35 @@ const CustomMethodCardWizardContainer = memo( }, [resetFieldTypeDrafts]); const handleFileChosen = useCallback( - (e: React.ChangeEvent) => { + async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; setUploadFileName(file?.name); + setUploadAssetUrl(undefined); + setUploadFieldError(null); + if (!file || !onPersistCustomUploadFile) return; + setUploadFieldBusy(true); + try { + const { url } = await onPersistCustomUploadFile(file); + setUploadAssetUrl(url); + } catch { + setUploadFieldError(tUpload("errors.generic")); + } finally { + setUploadFieldBusy(false); + } }, - [], + [onPersistCustomUploadFile, tUpload], ); + const handleClearPendingUpload = useCallback(() => { + setUploadFileName(undefined); + setUploadAssetUrl(undefined); + setUploadFieldError(null); + setUploadFieldBusy(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, []); + const handleBadgeAddOption = useCallback((label: string) => { setBadgeOptions((prev) => prev.includes(label) ? prev : [...prev, label], @@ -253,6 +308,9 @@ const CustomMethodCardWizardContainer = memo( id, blockTitle: uploadBlockTitle.trim(), fileName: uploadFileName, + ...(uploadAssetUrl?.trim() + ? { assetUrl: uploadAssetUrl.trim() } + : {}), }; break; default: @@ -276,6 +334,7 @@ const CustomMethodCardWizardContainer = memo( textPlaceholderBody, uploadBlockTitle, uploadFileName, + uploadAssetUrl, ]); const handleNext = useCallback(() => { @@ -337,6 +396,13 @@ const CustomMethodCardWizardContainer = memo( onUploadBlockTitleChange: setUploadBlockTitle, fileInputRef, onFileChosen: handleFileChosen, + onClearPendingUpload: handleClearPendingUpload, + uploadAssetPreviewUrl: uploadAssetUrl, + uploadPersisting: + Boolean(fieldTypeModal === "upload" && uploadFieldBusy), + uploadBusyHint: tUpload("uploading"), + uploadErrorMessage: + fieldTypeModal === "upload" ? uploadFieldError : null, proportionBlockTitle, proportionDefault, onProportionBlockTitleChange: setProportionBlockTitle, @@ -348,6 +414,8 @@ const CustomMethodCardWizardContainer = memo( onBack={handleBack} onNext={handleNext} stepper={!fieldTypeModal} + draftFieldBlocks={draftFieldBlocks} + onDraftFieldBlocksReorder={setDraftFieldBlocks} /> ); }, diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts index 4c99ef0..65b33c8 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts @@ -21,6 +21,9 @@ export interface CustomMethodCardWizardFieldBodiesCopy { blockTitlePlaceholder: string; uploadFileInputAriaLabel: string; uploadHint: string; + uploadPreviewImageAlt: string; + clearPendingUploadAriaLabel: string; + clearPendingUploadTooltip: string; }; proportion: { blockTitleLabel: string; @@ -35,6 +38,11 @@ export interface CustomMethodCardWizardCopy { step1: { title: string; description: string; fieldPlaceholder: string }; step2: { title: string; description: string; fieldPlaceholder: string }; step3: { title: string; description: string }; + step3BlocksList: { + listLabel: string; + dragHandleAriaLabel: string; + }; + fieldTypeLabels: Record; footerFinalize: string; fieldModals: { addField: string; @@ -67,6 +75,11 @@ export interface CustomMethodCardWizardProps { description: string; fieldBlocks: CustomMethodCardFieldBlock[]; }) => void; + /** + * Persists custom-method upload files to `POST /api/uploads` (purpose + * `customMethodAttachment`). When omitted, upload field only stores `fileName`. + */ + onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>; } export interface CustomMethodCardWizardFieldBodiesViewProps { @@ -84,6 +97,15 @@ export interface CustomMethodCardWizardFieldBodiesViewProps { onUploadBlockTitleChange: (_v: string) => void; fileInputRef: RefObject; onFileChosen: (e: React.ChangeEvent) => void; + /** Clears chosen file, preview URL, and related errors so the user can pick again. */ + onClearPendingUpload: () => void; + /** When set after a successful upload, shows an inline image preview in the modal. */ + uploadAssetPreviewUrl?: string | null; + /** Shown under the upload control while saving to the server. */ + uploadPersisting?: boolean; + /** Replaces upload hint text while `uploadPersisting` is true. */ + uploadBusyHint?: string; + uploadErrorMessage?: string | null; proportionBlockTitle: string; proportionDefault: number; onProportionBlockTitleChange: (_v: string) => void; @@ -111,6 +133,8 @@ export interface CustomMethodCardWizardViewProps { CustomMethodCardWizardFieldBodiesViewProps, "fieldType" | "copy" >; + draftFieldBlocks: CustomMethodCardFieldBlock[]; + onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; nextDisabled: boolean; nextLabel: string; showBackButton: boolean; diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx index b51ee12..1c6fb51 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx @@ -6,6 +6,7 @@ import InputWithCounter from "../../../../components/controls/InputWithCounter"; import TextArea from "../../../../components/controls/TextArea"; import AddCustomField from "../../../../components/controls/AddCustomField"; import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view"; +import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view"; import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types"; function CustomMethodCardWizardViewComponent({ @@ -32,6 +33,8 @@ function CustomMethodCardWizardViewComponent({ onBack, onNext, stepper, + draftFieldBlocks, + onDraftFieldBlocksReorder, }: CustomMethodCardWizardViewProps) { return ( ) : null} {!fieldTypeModal && wizardStep === 3 ? ( - +
+ {draftFieldBlocks.length > 0 ? ( + + ) : null} + +
) : null}
); diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx new file mode 100644 index 0000000..f27eebb --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { memo, useCallback, useState, type DragEvent } from "react"; +import Icon from "../../../../components/asset/icon"; +import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types"; +import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; +import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; +import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks"; + +function DragHandleGlyph({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} + +export interface CustomMethodCardWizardBlocksListViewProps { + blocks: CustomMethodCardFieldBlock[]; + fieldTypeLabels: Record; + dragHandleAriaLabel: string; + listLabel: string; + onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; +} + +function CustomMethodCardWizardBlocksListViewComponent({ + blocks, + fieldTypeLabels, + dragHandleAriaLabel, + listLabel, + onBlocksReorder, +}: CustomMethodCardWizardBlocksListViewProps) { + const [draggingIndex, setDraggingIndex] = useState(null); + const [overIndex, setOverIndex] = useState(null); + + const clearDragUi = useCallback(() => { + setDraggingIndex(null); + setOverIndex(null); + }, []); + + const handleDragStart = useCallback( + (index: number) => (e: DragEvent) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + setDraggingIndex(index); + }, + [], + ); + + const handleDragOver = useCallback((index: number) => { + return (e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setOverIndex(index); + }; + }, []); + + const handleDrop = useCallback( + (index: number) => (e: DragEvent) => { + e.preventDefault(); + const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); + if (Number.isNaN(from)) { + clearDragUi(); + return; + } + onBlocksReorder( + reorderCustomMethodCardFieldBlocks(blocks, from, index), + ); + clearDragUi(); + }, + [blocks, clearDragUi, onBlocksReorder], + ); + + return ( +
    + {blocks.map((block, index) => { + const kind = block.kind as AddCustomFieldType; + const typeLabel = fieldTypeLabels[kind]; + const isOver = overIndex === index && draggingIndex !== index; + return ( +
  • + + + + +
    + + {block.blockTitle.trim() || typeLabel} + + + {typeLabel} + +
    +
  • + ); + })} +
+ ); +} + +export const CustomMethodCardWizardBlocksListView = memo( + CustomMethodCardWizardBlocksListViewComponent, +); +CustomMethodCardWizardBlocksListView.displayName = + "CustomMethodCardWizardBlocksListView"; diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx index 643bdd8..f57709a 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardFieldBodies.view.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { getAssetPath } from "../../../../../lib/assetUtils"; import InputWithCounter from "../../../../components/controls/InputWithCounter"; import TextArea from "../../../../components/controls/TextArea"; import TextInput from "../../../../components/controls/TextInput"; @@ -28,11 +29,19 @@ function CustomMethodCardWizardFieldBodiesViewComponent({ onUploadBlockTitleChange, fileInputRef, onFileChosen, + onClearPendingUpload, + uploadAssetPreviewUrl = null, + uploadPersisting = false, + uploadBusyHint, + uploadErrorMessage = null, proportionBlockTitle, proportionDefault, onProportionBlockTitleChange, onProportionDefaultChange, }: CustomMethodCardWizardFieldBodiesViewProps) { + const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? ""; + const hasUploadPreview = uploadPreviewTrimmed.length > 0; + if (fieldType === "text") { return (
@@ -120,10 +129,53 @@ function CustomMethodCardWizardFieldBodiesViewComponent({ maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} showHelpIcon /> - fileInputRef.current?.click()} - /> + {hasUploadPreview ? ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element -- blob or same-origin upload URL */} + {copy.upload.uploadPreviewImageAlt} +
+ ) : ( + { + if (!uploadPersisting) fileInputRef.current?.click(); + }} + /> + )} + {uploadErrorMessage ? ( +

+ {uploadErrorMessage} +

+ ) : null}
); } diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 16e9f47..a4ea05c 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -31,6 +31,7 @@ import { } from "../../components/createFlowLayoutTokens"; import { CommunicationMethodEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; +import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; @@ -146,6 +147,8 @@ export function CommunicationMethodsScreen() { const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( createModalOpen ? pendingCardId : null, ); + const customModalReadOnly = + pendingCardId !== null && selectedIds.includes(pendingCardId); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); @@ -293,7 +296,9 @@ export function CommunicationMethodsScreen() { key={pendingCardId} cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} - onFieldBlocksChange={onCustomFieldBlocksChange} + onFieldBlocksChange={ + customModalReadOnly ? undefined : onCustomFieldBlocksChange + } /> ) : ( + uploadCreateFlowFile(file, "customMethodAttachment") + } /> ); diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index fd5a357..6e8f6b4 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -28,6 +28,7 @@ import { } from "../../components/createFlowLayoutTokens"; import { ConflictManagementEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; +import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; @@ -147,6 +148,8 @@ export function ConflictManagementScreen() { const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( createModalOpen ? pendingCardId : null, ); + const customModalReadOnly = + pendingCardId !== null && selectedIds.includes(pendingCardId); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); @@ -294,7 +297,9 @@ export function ConflictManagementScreen() { key={pendingCardId} cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} - onFieldBlocksChange={onCustomFieldBlocksChange} + onFieldBlocksChange={ + customModalReadOnly ? undefined : onCustomFieldBlocksChange + } /> ) : ( + uploadCreateFlowFile(file, "customMethodAttachment") + } /> ); diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 28c2ae1..599931a 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -29,6 +29,7 @@ import { } from "../../components/createFlowLayoutTokens"; import { MembershipMethodEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; +import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; @@ -144,6 +145,8 @@ export function MembershipMethodsScreen() { const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( createModalOpen ? pendingCardId : null, ); + const customModalReadOnly = + pendingCardId !== null && selectedIds.includes(pendingCardId); const handleCreateModalClose = useCallback(() => { setCreateModalOpen(false); @@ -287,7 +290,9 @@ export function MembershipMethodsScreen() { key={pendingCardId} cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} - onFieldBlocksChange={onCustomFieldBlocksChange} + onFieldBlocksChange={ + customModalReadOnly ? undefined : onCustomFieldBlocksChange + } /> ) : ( + uploadCreateFlowFile(file, "customMethodAttachment") + } /> ); diff --git a/app/(app)/create/screens/review/CommunityReviewScreen.tsx b/app/(app)/create/screens/review/CommunityReviewScreen.tsx index c5f164b..af19ec4 100644 --- a/app/(app)/create/screens/review/CommunityReviewScreen.tsx +++ b/app/(app)/create/screens/review/CommunityReviewScreen.tsx @@ -86,6 +86,12 @@ export function CommunityReviewScreen() { ? state.communityContext.trim() : undefined; + const avatarUrl = + typeof state.communityAvatarUrl === "string" && + state.communityAvatarUrl.trim().length > 0 + ? state.communityAvatarUrl.trim() + : null; + return ( diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index d665729..24c5fb6 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -30,6 +30,7 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { DecisionApproachEditFields } from "../../components/methodEditFields"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; +import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; @@ -143,6 +144,8 @@ export function DecisionApproachesScreen() { const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange( createModalOpen ? pendingCardId : null, ); + const customModalReadOnly = + pendingCardId !== null && selectedIds.includes(pendingCardId); const handleToggleExpand = useCallback(() => { markCreateFlowInteraction(); @@ -333,7 +336,9 @@ export function DecisionApproachesScreen() { key={pendingCardId} cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} - onFieldBlocksChange={onCustomFieldBlocksChange} + onFieldBlocksChange={ + customModalReadOnly ? undefined : onCustomFieldBlocksChange + } /> ) : ( + uploadCreateFlowFile(file, "customMethodAttachment") + } /> ); diff --git a/app/(app)/create/screens/upload/CommunityUploadScreen.tsx b/app/(app)/create/screens/upload/CommunityUploadScreen.tsx index b1681b2..76b00c2 100644 --- a/app/(app)/create/screens/upload/CommunityUploadScreen.tsx +++ b/app/(app)/create/screens/upload/CommunityUploadScreen.tsx @@ -1,21 +1,145 @@ "use client"; +import { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, +} from "react"; import Upload from "../../../../components/controls/Upload"; -import { useMessages } from "../../../../contexts/MessagesContext"; +import { useMessages, useTranslation } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; +import { fetchAuthSession } from "../../../../../lib/create/api"; +import { getAssetPath } from "../../../../../lib/assetUtils"; +import { + UploadToServerError, + uploadCreateFlowFile, +} from "../../../../../lib/create/uploadToServer"; +import { + clearPendingCommunityAvatarFile, + storePendingCommunityAvatarFile, +} from "../../../../../lib/create/pendingCommunityAvatarUpload"; /** Create Community — Figma Flow — Upload `20094:41524`. */ export function CommunityUploadScreen() { const m = useMessages(); const u = m.create.community.communityUpload; - const { markCreateFlowInteraction } = useCreateFlow(); + const { markCreateFlowInteraction, state, updateState } = useCreateFlow(); + const tUpload = useTranslation("create.upload"); - const handleUploadClick = () => { + const fileInputRef = useRef(null); + const [signedIn, setSignedIn] = useState(null); + const [localPreviewUrl, setLocalPreviewUrl] = useState(null); + const [busy, setBusy] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchAuthSession().then(({ user }) => { + if (!cancelled) setSignedIn(Boolean(user)); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => { + if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl); + }, + [localPreviewUrl], + ); + + const resolveUploadError = useCallback( + (err: unknown) => { + if (err instanceof UploadToServerError) { + if (err.status === 413) return tUpload("errors.tooLarge"); + if (err.status === 401) return tUpload("errors.unauthorized"); + if (err.code === "server_misconfigured") { + return tUpload("errors.misconfigured"); + } + } + return tUpload("errors.generic"); + }, + [tUpload], + ); + + const handleFileChange = useCallback( + async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + markCreateFlowInteraction(); + setErrorMessage(null); + + if (signedIn) { + setBusy(true); + try { + const { url } = await uploadCreateFlowFile(file, "communityAvatar"); + setLocalPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + updateState({ communityAvatarUrl: url }); + } catch (err) { + setErrorMessage(resolveUploadError(err)); + } finally { + setBusy(false); + } + return; + } + + if (signedIn === false) { + try { + await storePendingCommunityAvatarFile(file); + setLocalPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(file); + }); + } catch { + setErrorMessage(tUpload("errors.generic")); + } + } + }, + [ + markCreateFlowInteraction, + resolveUploadError, + signedIn, + tUpload, + updateState, + ], + ); + + const handleClearPendingUpload = useCallback(() => { markCreateFlowInteraction(); - }; + setErrorMessage(null); + setLocalPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + if ( + typeof state.communityAvatarUrl === "string" && + state.communityAvatarUrl.trim().length > 0 + ) { + updateState({ communityAvatarUrl: undefined }); + } + // Clear any anonymous staged blob so the post-sign-in flush won't resurrect it. + void clearPendingCommunityAvatarFile(); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]); + + const displaySrc = + typeof state.communityAvatarUrl === "string" && + state.communityAvatarUrl.trim().length > 0 + ? state.communityAvatarUrl.trim() + : localPreviewUrl; + const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; return (
-
- + +
+ {hasPreview ? ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element -- user/device file or same-origin upload URL */} + {u.previewAlt} +
+ ) : ( + { + if (!busy) fileInputRef.current?.click(); + }} + /> + )} + {signedIn === false ? ( +

+ {u.signInToUploadNote} +

+ ) : null} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null}
diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 1992b5a..daaf124 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -108,6 +108,11 @@ export interface CreateFlowState { communityContext?: string; /** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */ communitySaveEmail?: string; + /** + * Public app path for the uploaded community image (e.g. `/api/uploads/{uuid}`). + * Set after successful `POST /api/uploads` with purpose `communityAvatar`. + */ + communityAvatarUrl?: string; /** Selected chip ids from `community-size` (MultiSelect). */ selectedCommunitySizeIds?: string[]; /** Selected chip ids from `community-structure` (organization types). */ diff --git a/app/(app)/create/utils/anonymousDraftStorage.ts b/app/(app)/create/utils/anonymousDraftStorage.ts index 7ab465a..d53cd29 100644 --- a/app/(app)/create/utils/anonymousDraftStorage.ts +++ b/app/(app)/create/utils/anonymousDraftStorage.ts @@ -1,5 +1,6 @@ import type { CreateFlowState } from "../types"; import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState"; +import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload"; /** Anonymous in-progress create flow (local only until magic-link transfer). */ export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const; @@ -53,6 +54,7 @@ export function clearAnonymousCreateFlowStorage(): void { } catch { // ignore } + void clearPendingCommunityAvatarFile(); } export function setTransferPendingFlag(): void { diff --git a/app/api/uploads/[id]/route.ts b/app/api/uploads/[id]/route.ts new file mode 100644 index 0000000..e1359d4 --- /dev/null +++ b/app/api/uploads/[id]/route.ts @@ -0,0 +1,42 @@ +import { readFile } from "node:fs/promises"; +import { NextResponse } from "next/server"; +import { apiRoute } from "../../../../lib/server/apiRoute"; +import { + notFound, + serverMisconfigured, +} from "../../../../lib/server/responses"; +import { resolveUploadedFileById } from "../../../../lib/server/uploads/resolveUploadedFile"; +import { getUploadRootFromEnv } from "../../../../lib/server/uploads/uploadRoot"; + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * Public read for opaque upload ids (no auth). Unguessable UUID stem; + * do not use for sensitive documents without revisiting policy. + */ +export const GET = apiRoute( + "uploads.byId", + async (_request, context) => { + if (!getUploadRootFromEnv()) { + return serverMisconfigured( + "File uploads are not configured (UPLOAD_ROOT is unset).", + ); + } + + const { id } = await context.params; + const resolved = await resolveUploadedFileById(id); + if (!resolved) { + return notFound("Upload not found"); + } + + const body = await readFile(resolved.absolutePath); + return new NextResponse(new Uint8Array(body), { + status: 200, + headers: { + "Content-Type": resolved.contentType, + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }, + }); + }, +); diff --git a/app/api/uploads/route.ts b/app/api/uploads/route.ts new file mode 100644 index 0000000..f16d086 --- /dev/null +++ b/app/api/uploads/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isDatabaseConfigured } from "../../../lib/server/env"; +import { + dbUnavailable, + errorJson, + serverMisconfigured, + unauthorized, + rateLimited, +} from "../../../lib/server/responses"; +import { getSessionUser } from "../../../lib/server/session"; +import { apiRoute } from "../../../lib/server/apiRoute"; +import { rateLimitKey } from "../../../lib/server/rateLimit"; +import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlowUpload"; +import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot"; +import { + CREATE_FLOW_UPLOAD_MAX_BYTES, + type CreateFlowUploadPurpose, +} from "../../../lib/server/uploads/uploadConstants"; + +function isPurpose(x: string): x is CreateFlowUploadPurpose { + return x === "communityAvatar" || x === "customMethodAttachment"; +} + +export const POST = apiRoute("uploads.post", async (request: NextRequest) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const user = await getSessionUser(); + if (!user) { + return unauthorized(); + } + + if (!getUploadRootFromEnv()) { + return serverMisconfigured( + "File uploads are not configured (UPLOAD_ROOT is unset).", + ); + } + + const rl = rateLimitKey(`upload:${user.id}`, 5_000); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return errorJson( + "payload_too_large", + "Upload body is too large or malformed.", + 413, + ); + } + + const purposeRaw = formData.get("purpose"); + const file = formData.get("file"); + + if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { + return errorJson( + "validation_error", + "Invalid or missing `purpose` (expected communityAvatar | customMethodAttachment).", + 400, + ); + } + + if (!(file instanceof File)) { + return errorJson( + "validation_error", + "Missing `file` field (multipart file).", + 400, + ); + } + + if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) { + return errorJson( + "payload_too_large", + `File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`, + 413, + ); + } + + const buf = Buffer.from(await file.arrayBuffer()); + const mimeType = file.type || "application/octet-stream"; + + const saved = await saveCreateFlowUpload({ + purpose: purposeRaw, + buffer: buf, + mimeType, + }); + + if ("error" in saved) { + if (saved.error === "misconfigured") { + return serverMisconfigured( + "File uploads are not configured (UPLOAD_ROOT is unset).", + ); + } + return errorJson( + "validation_error", + "File type or size is not allowed for this upload purpose.", + 400, + ); + } + + return NextResponse.json({ + url: saved.urlPath, + id: saved.id, + mimeType: saved.mimeType, + byteLength: saved.byteLength, + }); +}); diff --git a/app/components/controls/AddCustomField/AddCustomField.types.ts b/app/components/controls/AddCustomField/AddCustomField.types.ts index e067d92..b8d8def 100644 --- a/app/components/controls/AddCustomField/AddCustomField.types.ts +++ b/app/components/controls/AddCustomField/AddCustomField.types.ts @@ -1,5 +1,15 @@ +import type { IconName } from "../../asset/icon"; + export type AddCustomFieldType = "text" | "badges" | "upload" | "proportion"; +/** Icons for each addable field type (wizard + summaries). */ +export const ADD_CUSTOM_FIELD_TYPE_ICONS = { + text: "text_block", + badges: "tags", + upload: "image", + proportion: "number", +} as const satisfies Record; + export interface AddCustomFieldProps { /** When true, show the 2×2 field-type grid; when false, show the primary CTA. */ active: boolean; diff --git a/app/components/controls/AddCustomField/AddCustomField.view.tsx b/app/components/controls/AddCustomField/AddCustomField.view.tsx index a3d038b..060b685 100644 --- a/app/components/controls/AddCustomField/AddCustomField.view.tsx +++ b/app/components/controls/AddCustomField/AddCustomField.view.tsx @@ -1,20 +1,14 @@ "use client"; import { memo } from "react"; -import Icon, { type IconName } from "../../asset/icon"; +import Icon from "../../asset/icon"; import Vertical from "../../buttons/Vertical"; -import type { - AddCustomFieldType, - AddCustomFieldViewProps, +import { + ADD_CUSTOM_FIELD_TYPE_ICONS, + type AddCustomFieldType, + type AddCustomFieldViewProps, } from "./AddCustomField.types"; -const FIELD_TYPE_ICONS: Record = { - text: "text_block", - badges: "tags", // tag / chip list (filename: tags.svg) - upload: "image", // image / file upload (filename: image.svg) - proportion: "number", // numeric / proportion field (closest asset: number.svg) -}; - function FieldTypeButton({ type, label, @@ -32,7 +26,7 @@ function FieldTypeButton({ > diff --git a/docs/guides/backend-linear-tickets.md b/docs/guides/backend-linear-tickets.md index 6a25c8c..7a6f56c 100644 --- a/docs/guides/backend-linear-tickets.md +++ b/docs/guides/backend-linear-tickets.md @@ -493,6 +493,43 @@ _Section B — Final Review screen `+` button per category:_ --- +## Ticket 21 — Create flow file uploads (community photo + custom method attachments) + +**Depends on:** **Ticket 3 / [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done)** (session for authenticated routes); **Ticket 5 / [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer)** (draft JSON carries new URL fields). **Related:** **[CR-58](https://linear.app/community-rule/issue/CR-58)** (Figma Avatar / broader upload routing — may share `/api/uploads` later). + +**Server / admin:** Persist uploads on disk under **`UPLOAD_ROOT`** (document in [`.env.example`](../../.env.example)); production aligns with Cloudron **localstorage** mount ([`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md)). **Not** storing binaries inside `RuleDraft.payload` / publish JSON — only **HTTPS-relative URLs** (or opaque ids served by the app). + +**Goal:** Replace **filename-only** / **stub** upload UX with real files for (1) **Create Community** `community-upload` and (2) **custom method** wizard / modal **upload** field blocks. Draft and publish JSON carry stable URLs like other strings. + +**Context (repo today):** + +- [`CommunityUploadScreen.tsx`](../../app/(app)/create/screens/upload/CommunityUploadScreen.tsx) has no `` or `CreateFlowState` image field. +- [`lib/create/customMethodCardFieldBlocks.ts`](../../lib/create/customMethodCardFieldBlocks.ts) upload blocks have optional **`fileName`** only. +- **Anonymous uploads:** `POST` must stay **`getSessionUser()` required** (same as [`PUT /api/drafts/me`](../../app/api/drafts/me/route.ts)). **Do not** ship unauthenticated multipart without abuse controls. **Recommended:** client **staging** (prefer **IndexedDB**) + **flush** after session via one helper (e.g. after [`PostLoginDraftTransfer`](../../app/(app)/create/PostLoginDraftTransfer.tsx) / session-ready); **alt:** gate picker until signed in. + +**Implementation (sketch):** + +1. **`lib/server/uploads/`** — save stream to UUID filename under `UPLOAD_ROOT`; resolve path safely (no traversal). +2. **`POST /api/uploads`** — `apiRoute`, multipart `formData`, `purpose` enum (`communityAvatar` | `customMethodAttachment` or similar), MIME + size allowlists, optional rate limit alignment with magic-link patterns. +3. **`GET /api/uploads/[id]`** (or equivalent) — stream file by opaque id; document **public vs session-gated** read policy for v1. +4. **Client:** thin `uploadCreateFlowFile(file, purpose)` (e.g. under `lib/create/`); i18n for errors; loading UX per alerts rules. +5. **State + Zod:** `communityAvatarUrl` (name TBD) on [`CreateFlowState`](../../app/(app)/create/types.ts); upload blocks gain **`assetUrl`** (keep `fileName` for display); [`createFlowSchemas.ts`](../../lib/server/validation/createFlowSchemas.ts) updated. +6. **UI:** wire [`CommunityUploadScreen`](../../app/(app)/create/screens/upload/CommunityUploadScreen.tsx); wizard + [`CustomMethodCardFieldBlocksSummary`](../../app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx). +7. **Publish:** extend [`buildPublishPayload.ts`](../../lib/create/buildPublishPayload.ts) / `document` **if** product needs URLs for readers; else TODO in ticket / PR. + +**Acceptance criteria:** + +- [ ] Signed-in user completes upload on community step; URL persists in draft when sync is on. +- [ ] Custom-method upload block stores `assetUrl` after upload; visible in modal / final review paths. +- [ ] Anonymous flow does not claim server upload without session; agreed staging strategy works or picker is gated with copy. +- [ ] Unit tests: validation, path safety; targeted Vitest for happy path where practical. + +**Linear:** [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method) (**Backlog**). + +**Internal plan:** Cursor plan `create_flow_real_uploads_ebeecca5.plan.md` (workspace `.cursor/plans/`) expands sequence diagrams and rollout order. + +--- + ## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM) **Depends on:** none (orthogonal). @@ -732,16 +769,17 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule** | 18 | 18 | Stakeholder invites (confirm-stakeholders) | | 19 | 19 | `Add` button behavior (custom-rule pages + Final Review) | | 20 | 20 | Change account email (verified) **Backlog — CR-103** | +| 21 | 21 | Create flow file uploads **Backlog — [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)** | **Follow-up (no doc ticket #):** **[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)** — marketing template grids ranked by user facets (API-ready; tests deferred with that issue). -Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 7–8**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 13–14** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** tracks **verified change account email** (split from Ticket 15). **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands. +Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 7–8**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 13–14** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** tracks **verified change account email** (split from Ticket 15). **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands. **Ticket 21** (**[CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)**) — create-flow **authenticated file uploads** for community photo + custom-method attachment blocks; see Ticket 21 body in this doc. Implementation follows [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md) localstorage guidance and avoids unauthenticated multipart in v1. --- ## Linear (Community-rule team) -**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior). +**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior); **Ticket 21 / [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)** (create-flow file uploads). | Doc ticket | Linear | Title (short) | | ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | @@ -773,6 +811,7 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts + | 18 | [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) | Stakeholder invites (confirm-stakeholders) | | 19 | [CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final) | `Add` button behavior (custom-rule + Final Review) | | 20 | [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session) | Change account email (verify new address) **Backlog** | +| 21 | [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method) | Create flow file uploads (community + custom blocks) **Backlog** | --- diff --git a/docs/guides/ops-backend-deploy.md b/docs/guides/ops-backend-deploy.md index 6e82f41..e3dd507 100644 --- a/docs/guides/ops-backend-deploy.md +++ b/docs/guides/ops-backend-deploy.md @@ -75,6 +75,10 @@ per-app in the manifest and provisioned at install time. with the legacy service; SES relay accepts it). - `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` — turns on Postgres draft persistence for signed-in users. Required in production. +- `UPLOAD_ROOT` — absolute path to a writable directory (typically on the + Cloudron **localstorage** mount) for `POST /api/uploads` (community photo + + custom-method attachments). When unset, upload routes return + `server_misconfigured`. See [CONTRIBUTING.md](../../CONTRIBUTING.md) API table. ## 4. Platform settings diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index 3d8e8e5..d5a2199 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -161,6 +161,20 @@ export function buildPublishPayload( document.methodSelections = methodSelections; } + const avatar = + typeof state.communityAvatarUrl === "string" && + state.communityAvatarUrl.trim().length > 0 + ? state.communityAvatarUrl.trim() + : undefined; + if (avatar) { + document.communityAvatarUrl = avatar; + } + + const fieldBlocks = state.customMethodCardFieldBlocksById; + if (fieldBlocks && Object.keys(fieldBlocks).length > 0) { + document.customMethodCardFieldBlocksById = fieldBlocks; + } + if (summary !== undefined) { return { ok: true, title, summary, document }; } diff --git a/lib/create/createFlowUploadPurpose.ts b/lib/create/createFlowUploadPurpose.ts new file mode 100644 index 0000000..9da9a73 --- /dev/null +++ b/lib/create/createFlowUploadPurpose.ts @@ -0,0 +1,8 @@ +/** Multipart field `purpose` for `POST /api/uploads` — keep in sync with server validation. */ +export const CREATE_FLOW_UPLOAD_PURPOSES = [ + "communityAvatar", + "customMethodAttachment", +] as const; + +export type CreateFlowUploadPurpose = + (typeof CREATE_FLOW_UPLOAD_PURPOSES)[number]; diff --git a/lib/create/customMethodCardFieldBlocks.ts b/lib/create/customMethodCardFieldBlocks.ts index de98a79..1970d5a 100644 --- a/lib/create/customMethodCardFieldBlocks.ts +++ b/lib/create/customMethodCardFieldBlocks.ts @@ -19,6 +19,8 @@ export type CustomMethodCardFieldBlock = id: string; blockTitle: string; fileName?: string; + /** App path from `POST /api/uploads` (e.g. `/api/uploads/{uuid}`). */ + assetUrl?: string; } | { kind: "proportion"; @@ -51,6 +53,7 @@ const customMethodUploadBlockSchema = z id: z.string().max(80), blockTitle: z.string().max(200), fileName: z.string().max(500).optional(), + assetUrl: z.string().max(512).optional(), }) .strict(); diff --git a/lib/create/pendingCommunityAvatarUpload.ts b/lib/create/pendingCommunityAvatarUpload.ts new file mode 100644 index 0000000..16213d0 --- /dev/null +++ b/lib/create/pendingCommunityAvatarUpload.ts @@ -0,0 +1,73 @@ +/** + * IndexedDB staging for community avatar when the user picks a file before + * a session exists. Cleared after successful upload or explicit clear. + */ + +const DB_NAME = "community-rule-pending-uploads"; +const DB_VERSION = 1; +const STORE = "communityAvatar"; +const KEY = "pending"; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error ?? new Error("indexedDB open failed")); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE); + } + }; + }); +} + +export async function storePendingCommunityAvatarFile(file: File): Promise { + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("indexedDB write failed")); + tx.objectStore(STORE).put(file, KEY); + }); + } finally { + db.close(); + } +} + +/** Read staged file without removing it (caller clears after successful upload). */ +export async function readPendingCommunityAvatarFile(): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed")); + const getReq = tx.objectStore(STORE).get(KEY); + getReq.onsuccess = () => { + const v = getReq.result; + resolve(v instanceof File ? v : null); + }; + getReq.onerror = () => reject(getReq.error); + }); + } finally { + db.close(); + } +} + +export async function clearPendingCommunityAvatarFile(): Promise { + if (typeof indexedDB === "undefined") return; + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("indexedDB clear failed")); + tx.objectStore(STORE).delete(KEY); + }); + } catch { + // ignore missing DB / quota + } finally { + db.close(); + } +} diff --git a/lib/create/publishedDocumentToCreateFlowState.ts b/lib/create/publishedDocumentToCreateFlowState.ts index d86359c..ae7cdcd 100644 --- a/lib/create/publishedDocumentToCreateFlowState.ts +++ b/lib/create/publishedDocumentToCreateFlowState.ts @@ -113,6 +113,24 @@ export function createFlowStateFromPublishedRule( out.coreValueDetailsByChipId = coreValueDetailsByChipId; } + const avatarUrl = + typeof doc.communityAvatarUrl === "string" + ? doc.communityAvatarUrl.trim() + : ""; + if (avatarUrl.length > 0) { + out.communityAvatarUrl = avatarUrl; + } + + const blocksRaw = doc.customMethodCardFieldBlocksById; + if ( + blocksRaw && + typeof blocksRaw === "object" && + !Array.isArray(blocksRaw) + ) { + out.customMethodCardFieldBlocksById = + blocksRaw as NonNullable; + } + const msRaw = doc.methodSelections; if (!msRaw || typeof msRaw !== "object" || Array.isArray(msRaw)) { out.sections = []; diff --git a/lib/create/reorderCustomMethodCardFieldBlocks.ts b/lib/create/reorderCustomMethodCardFieldBlocks.ts new file mode 100644 index 0000000..5805da5 --- /dev/null +++ b/lib/create/reorderCustomMethodCardFieldBlocks.ts @@ -0,0 +1,18 @@ +/** + * Immutable reorder for custom method card field blocks (wizard step 3, edit modal). + */ +export function reorderCustomMethodCardFieldBlocks( + blocks: readonly T[], + fromIndex: number, + toIndex: number, +): T[] { + if (fromIndex === toIndex) return [...blocks]; + if (fromIndex < 0 || toIndex < 0 || fromIndex >= blocks.length) { + return [...blocks]; + } + if (toIndex >= blocks.length) return [...blocks]; + const next = [...blocks]; + const [removed] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, removed); + return next; +} diff --git a/lib/create/uploadToServer.ts b/lib/create/uploadToServer.ts new file mode 100644 index 0000000..1d7ff8b --- /dev/null +++ b/lib/create/uploadToServer.ts @@ -0,0 +1,93 @@ +import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; + +export type UploadToServerResult = { + url: string; + id: string; + mimeType: string; + byteLength: number; +}; + +/** + * Authenticated multipart upload to `POST /api/uploads`. + * Caller must have a session cookie (same-origin fetch). + */ +export async function uploadCreateFlowFile( + file: File, + purpose: CreateFlowUploadPurpose, +): Promise { + const formData = new FormData(); + formData.append("purpose", purpose); + formData.append("file", file); + + const res = await fetch("/api/uploads", { + method: "POST", + body: formData, + credentials: "same-origin", + }); + + let body: unknown; + try { + body = await res.json(); + } catch { + body = {}; + } + + const errParts = (() => { + if (body && typeof body === "object" && "error" in body) { + const e = (body as { + error?: { message?: string; code?: string }; + }).error; + if (!e) return { message: null as string | null, code: null as string | null }; + return { + message: typeof e.message === "string" ? e.message : null, + code: typeof e.code === "string" ? e.code : null, + }; + } + return { message: null, code: null }; + })(); + + if (!res.ok) { + const fallback = + res.status === 413 + ? "FILE_TOO_LARGE" + : res.status === 401 + ? "UNAUTHORIZED" + : "UPLOAD_FAILED"; + const code = errParts.code ?? errParts.message ?? fallback; + throw new UploadToServerError(res.status, code); + } + + const data = body as { + url?: string; + id?: string; + mimeType?: string; + byteLength?: number; + }; + if ( + typeof data.url !== "string" || + typeof data.id !== "string" || + typeof data.mimeType !== "string" || + typeof data.byteLength !== "number" + ) { + throw new UploadToServerError(res.status, "INVALID_RESPONSE"); + } + + return { + url: data.url, + id: data.id, + mimeType: data.mimeType, + byteLength: data.byteLength, + }; +} + +export class UploadToServerError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string) { + super(code); + this.name = "UploadToServerError"; + this.status = status; + this.code = code; + } +} diff --git a/lib/server/uploads/resolveUploadedFile.ts b/lib/server/uploads/resolveUploadedFile.ts new file mode 100644 index 0000000..63239de --- /dev/null +++ b/lib/server/uploads/resolveUploadedFile.ts @@ -0,0 +1,59 @@ +import { constants as fsConstants } from "node:fs"; +import { access, readdir } from "node:fs/promises"; +import path from "node:path"; +import { getUploadRootFromEnv } from "./uploadRoot"; +import { isValidUploadFileId } from "./uploadConstants"; + +export type ResolvedUploadFile = { + absolutePath: string; + /** MIME inferred from extension (no sidecar file in v1). */ + contentType: string; +}; + +function contentTypeForFilename(fileName: string): string { + const ext = path.extname(fileName).toLowerCase(); + switch (ext) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".webp": + return "image/webp"; + case ".gif": + return "image/gif"; + case ".pdf": + return "application/pdf"; + default: + return "application/octet-stream"; + } +} + +/** + * Resolves `id` (UUID stem) to the single matching file `{id}.*` under UPLOAD_ROOT. + * Returns null if missing, ambiguous, or invalid id. + */ +export async function resolveUploadedFileById( + id: string, +): Promise { + if (!isValidUploadFileId(id)) return null; + const root = getUploadRootFromEnv(); + if (!root) return null; + + const entries = await readdir(root); + const prefix = `${id}.`; + const matches = entries.filter((e) => e.startsWith(prefix)); + if (matches.length !== 1) return null; + + const absolutePath = path.join(root, matches[0]!); + try { + await access(absolutePath, fsConstants.R_OK); + } catch { + return null; + } + + return { + absolutePath, + contentType: contentTypeForFilename(matches[0]!), + }; +} diff --git a/lib/server/uploads/saveCreateFlowUpload.ts b/lib/server/uploads/saveCreateFlowUpload.ts new file mode 100644 index 0000000..2ae6459 --- /dev/null +++ b/lib/server/uploads/saveCreateFlowUpload.ts @@ -0,0 +1,57 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { CreateFlowUploadPurpose } from "./uploadConstants"; +import { + extensionForMime, + isAllowedMime, + maxBytesForPurpose, +} from "./uploadConstants"; +import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot"; + +export type SaveCreateFlowUploadResult = { + /** Filename stem (UUID) without extension — used in GET URL. */ + id: string; + /** Full relative URL path for clients, e.g. `/api/uploads/abc`. */ + urlPath: string; + mimeType: string; + byteLength: number; +}; + +/** + * Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path. + */ +export async function saveCreateFlowUpload(params: { + purpose: CreateFlowUploadPurpose; + buffer: Buffer; + /** Declared MIME from the client `File.type` (validated server-side). */ + mimeType: string; +}): Promise { + const root = getUploadRootFromEnv(); + if (!root) { + return { error: "misconfigured" }; + } + + const { purpose, buffer, mimeType } = params; + if (buffer.length > maxBytesForPurpose(purpose)) { + return { error: "validation" }; + } + if (!isAllowedMime(purpose, mimeType)) { + return { error: "validation" }; + } + + const id = randomUUID(); + const ext = extensionForMime(mimeType); + const fileName = `${id}${ext}`; + const absolutePath = path.join(root, fileName); + + await ensureUploadRootExists(root); + await writeFile(absolutePath, buffer, { mode: 0o644 }); + + return { + id, + urlPath: `/api/uploads/${id}`, + mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream", + byteLength: buffer.length, + }; +} diff --git a/lib/server/uploads/uploadConstants.ts b/lib/server/uploads/uploadConstants.ts new file mode 100644 index 0000000..8864847 --- /dev/null +++ b/lib/server/uploads/uploadConstants.ts @@ -0,0 +1,62 @@ +import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose"; + +export type { CreateFlowUploadPurpose }; +export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose"; + +/** Max body size for multipart upload (bytes). */ +export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024; + +const COMMUNITY_MAX = 5 * 1024 * 1024; +const CUSTOM_MAX = 10 * 1024 * 1024; + +const IMAGE_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); + +const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]); + +export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number { + return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX; +} + +export function isAllowedMime( + purpose: CreateFlowUploadPurpose, + mime: string, +): boolean { + const m = mime.toLowerCase().split(";")[0]?.trim() ?? ""; + if (IMAGE_MIMES.has(m)) return true; + if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) { + return true; + } + return false; +} + +/** Extension including dot, from normalized mime (lowercase). */ +export function extensionForMime(mime: string): string { + const m = mime.toLowerCase().split(";")[0]?.trim() ?? ""; + switch (m) { + case "image/jpeg": + return ".jpg"; + case "image/png": + return ".png"; + case "image/webp": + return ".webp"; + case "image/gif": + return ".gif"; + case "application/pdf": + return ".pdf"; + default: + return ".bin"; + } +} + +/** Strict id: uuid v4 filename stem (no extension in id param for GET). */ +const UPLOAD_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isValidUploadFileId(id: string): boolean { + return UPLOAD_ID_RE.test(id); +} diff --git a/lib/server/uploads/uploadRoot.ts b/lib/server/uploads/uploadRoot.ts new file mode 100644 index 0000000..30e28c6 --- /dev/null +++ b/lib/server/uploads/uploadRoot.ts @@ -0,0 +1,16 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +/** + * Directory for persisted user uploads (Cloudron localstorage mount in prod). + * When unset, upload routes return `server_misconfigured`. + */ +export function getUploadRootFromEnv(): string | null { + const raw = process.env.UPLOAD_ROOT?.trim(); + if (!raw) return null; + return path.resolve(raw); +} + +export async function ensureUploadRootExists(root: string): Promise { + await mkdir(root, { recursive: true }); +} diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 7d5926c..b3e6fdd 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -89,6 +89,7 @@ export const createFlowStateSchema = z summary: z.string().max(8000).optional(), communityContext: z.string().max(200).optional(), communitySaveEmail: z.string().max(320).optional(), + communityAvatarUrl: z.string().max(512).optional(), selectedCommunitySizeIds: z.array(z.string()).optional(), selectedOrganizationTypeIds: z.array(z.string()).optional(), selectedScaleIds: z.array(z.string()).optional(), diff --git a/messages/en/create/community/communityUpload.json b/messages/en/create/community/communityUpload.json index 90f9491..ba56281 100644 --- a/messages/en/create/community/communityUpload.json +++ b/messages/en/create/community/communityUpload.json @@ -1,5 +1,10 @@ { "title": "Add a photo to identify your group", "description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.", - "hintText": "Add image from your device" + "hintText": "Add image from your device", + "signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).", + "uploadingLabel": "Uploading your photo…", + "previewAlt": "Selected community photo preview", + "clearPendingUploadAriaLabel": "Remove uploaded photo", + "clearPendingUploadTooltip": "Remove photo" } diff --git a/messages/en/create/customRule/customMethodCardWizard.json b/messages/en/create/customRule/customMethodCardWizard.json index 166b51e..ac20f2d 100644 --- a/messages/en/create/customRule/customMethodCardWizard.json +++ b/messages/en/create/customRule/customMethodCardWizard.json @@ -19,8 +19,12 @@ "description": "Configure a custom data structure for this policy by adding fields for text, proportions, multi-select options, or file uploads. This creates a reusable template with placeholders that allows your group to standardize how policy definitions are edited in the future." } }, + "step3BlocksList": { + "listLabel": "Fields added to this policy", + "dragHandleAriaLabel": "Drag to reorder this field" + }, "footer": { - "finalize": "Finalize policy" + "finalize": "Finalize" }, "editModal": { "placeholderBody": "This policy uses the title and description you set when you created it. Extra section fields from preset templates are hidden here so you are not shown empty boxes that do not match what you configured.", @@ -64,7 +68,10 @@ "blockTitleLabel": "Upload Block Title", "blockTitlePlaceholder": "Add your upload block title", "uploadFileInputAriaLabel": "Choose file for this upload block", - "uploadHint": "Add images, PDFs, and other files to the policy" + "uploadHint": "Add images, PDFs, and other files to the policy", + "uploadPreviewImageAlt": "Preview of uploaded file", + "clearPendingUploadAriaLabel": "Remove uploaded file", + "clearPendingUploadTooltip": "Remove file" }, "proportion": { "title": "Add proportion block", diff --git a/messages/en/create/upload.json b/messages/en/create/upload.json index 64fa00a..d3d7673 100644 --- a/messages/en/create/upload.json +++ b/messages/en/create/upload.json @@ -1,4 +1,9 @@ { - "title": "How should conflicts be resolved?", - "description": "Upload supporting materials or examples that help describe how your community handles conflict." + "errors": { + "generic": "Something went wrong while uploading. Try again.", + "tooLarge": "That file is too large. Try a smaller image or PDF.", + "unauthorized": "Sign in to upload files. Use Save progress if you started without an account.", + "misconfigured": "Uploads are not available on this server yet." + }, + "uploading": "Uploading…" } diff --git a/messages/en/index.ts b/messages/en/index.ts index 527eaf5..0e43de1 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -53,6 +53,7 @@ import createFooter from "./create/footer.json"; import createTopNav from "./create/topNav.json"; import createDraftHydration from "./create/draftHydration.json"; import createTemplateReview from "./create/templateReview.json"; +import createUpload from "./create/upload.json"; export default { common, @@ -107,6 +108,7 @@ export default { topNav: createTopNav, draftHydration: createDraftHydration, templateReview: createTemplateReview, + upload: createUpload, }, navigation, metadata, diff --git a/tests/unit/createFlowUploadConstants.test.ts b/tests/unit/createFlowUploadConstants.test.ts new file mode 100644 index 0000000..50edd0f --- /dev/null +++ b/tests/unit/createFlowUploadConstants.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + extensionForMime, + isAllowedMime, + isValidUploadFileId, + maxBytesForPurpose, +} from "../../lib/server/uploads/uploadConstants"; + +describe("createFlow upload constants", () => { + it("maxBytesForPurpose caps community smaller than custom attachment", () => { + expect(maxBytesForPurpose("communityAvatar")).toBe(5 * 1024 * 1024); + expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024); + }); + + it("isAllowedMime allows images for both purposes", () => { + expect(isAllowedMime("communityAvatar", "image/png")).toBe(true); + expect(isAllowedMime("customMethodAttachment", "image/jpeg")).toBe(true); + }); + + it("isAllowedMime allows pdf only for customMethodAttachment", () => { + expect(isAllowedMime("communityAvatar", "application/pdf")).toBe(false); + expect(isAllowedMime("customMethodAttachment", "application/pdf")).toBe( + true, + ); + }); + + it("extensionForMime maps common types", () => { + expect(extensionForMime("image/png")).toBe(".png"); + expect(extensionForMime("image/jpeg")).toBe(".jpg"); + expect(extensionForMime("application/pdf")).toBe(".pdf"); + }); + + it("isValidUploadFileId rejects traversal and non-uuid", () => { + expect(isValidUploadFileId("../etc/passwd")).toBe(false); + expect(isValidUploadFileId("not-a-uuid")).toBe(false); + expect( + isValidUploadFileId("550e8400-e29b-41d4-a716-446655440000"), + ).toBe(true); + }); +}); diff --git a/tests/unit/reorderCustomMethodCardFieldBlocks.test.ts b/tests/unit/reorderCustomMethodCardFieldBlocks.test.ts new file mode 100644 index 0000000..9c21c72 --- /dev/null +++ b/tests/unit/reorderCustomMethodCardFieldBlocks.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { reorderCustomMethodCardFieldBlocks } from "../../lib/create/reorderCustomMethodCardFieldBlocks"; + +describe("reorderCustomMethodCardFieldBlocks", () => { + it("returns a new array with the item moved forward", () => { + const blocks = ["a", "b", "c"]; + expect(reorderCustomMethodCardFieldBlocks(blocks, 0, 2)).toEqual([ + "b", + "c", + "a", + ]); + expect(blocks).toEqual(["a", "b", "c"]); + }); + + it("returns a new array with the item moved backward", () => { + expect( + reorderCustomMethodCardFieldBlocks(["a", "b", "c"], 2, 0), + ).toEqual(["c", "a", "b"]); + }); + + it("returns a shallow copy when from and to are equal", () => { + const blocks = ["a", "b"]; + const next = reorderCustomMethodCardFieldBlocks(blocks, 1, 1); + expect(next).toEqual(blocks); + expect(next).not.toBe(blocks); + }); + + it("returns a copy when indices are out of range", () => { + const blocks = ["a"]; + expect(reorderCustomMethodCardFieldBlocks(blocks, -1, 0)).toEqual(["a"]); + expect(reorderCustomMethodCardFieldBlocks(blocks, 0, 5)).toEqual(["a"]); + }); +});