Add button and custom modal flow implemented
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<IDBDatabase> {
|
||||
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<void> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
await new Promise<void>((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<File | null> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise<File | null>((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<void> {
|
||||
if (typeof indexedDB === "undefined") return;
|
||||
const db = await openDb();
|
||||
try {
|
||||
await new Promise<void>((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();
|
||||
}
|
||||
}
|
||||
@@ -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<CreateFlowState["customMethodCardFieldBlocksById"]>;
|
||||
}
|
||||
|
||||
const msRaw = doc.methodSelections;
|
||||
if (!msRaw || typeof msRaw !== "object" || Array.isArray(msRaw)) {
|
||||
out.sections = [];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Immutable reorder for custom method card field blocks (wizard step 3, edit modal).
|
||||
*/
|
||||
export function reorderCustomMethodCardFieldBlocks<T>(
|
||||
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;
|
||||
}
|
||||
@@ -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<UploadToServerResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ResolvedUploadFile | null> {
|
||||
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]!),
|
||||
};
|
||||
}
|
||||
@@ -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<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
await mkdir(root, { recursive: true });
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user