"use server"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { auth } from "@/auth"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { minioClient, bucketName } from "@/lib/minio"; // Import MinIO client import { v4 as uuidv4 } from "uuid"; // Max file size (e.g., 5MB) const MAX_FILE_SIZE = 5 * 1024 * 1024; // Allowed image types const ACCEPTED_IMAGE_TYPES = [ "image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", ]; // Update schema: image is now optional File const PostSchema = z.object({ title: z.string().min(3, "Title must be at least 3 characters"), slug: z .string() .min(3, "Slug must be at least 3 characters") .regex( /^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug must be lowercase alphanumeric with hyphens" ), content: z.string().min(10, "Content is too short"), excerpt: z.string().optional(), // Validate the image file image: z .instanceof(File) .optional() .refine( (file) => !file || file.size <= MAX_FILE_SIZE, `Max image size is 5MB.` ) .refine( (file) => !file || ACCEPTED_IMAGE_TYPES.includes(file.type), "Only .jpg, .jpeg, .png, .webp and .gif formats are supported." ), published: z.boolean().optional(), tags: z.string().optional(), // Handle tags if needed }); export type CreatePostState = { errors?: { title?: string[]; slug?: string[]; content?: string[]; image?: string[]; // Changed from imageUrl tags?: string[]; _form?: string[]; }; message?: string | null; // Add a field to hold previous input values previousInput?: { title?: string; slug?: string; content?: string; excerpt?: string; tags?: string; published?: boolean; // We don't repopulate the file input for security reasons }; }; // Helper function to upload to MinIO async function uploadImageToMinio(file: File): Promise { try { const fileExtension = file.name.split(".").pop(); const fileName = `${uuidv4()}.${fileExtension}`; const buffer = Buffer.from(await file.arrayBuffer()); await minioClient.putObject(bucketName, fileName, buffer, buffer.length, { "Content-Type": file.type, }); const baseUrl = process.env.MINIO_BASE_URL?.replace(/\/+$/, ""); const publicUrl = `${baseUrl}/${bucketName}/${fileName}`; return publicUrl; } catch (error) { console.error("MinIO Upload Error:", error); return null; } } export async function createPost( prevState: CreatePostState, formData: FormData ): Promise { const session = await auth(); if (!session?.user?.id) { // Check for user ID specifically return { errors: { _form: ["Authentication required."] }, message: null }; } // Store raw form data for potential error return const rawFormData = { title: formData.get("title")?.toString(), slug: formData.get("slug")?.toString(), content: formData.get("content")?.toString(), excerpt: formData.get("excerpt")?.toString(), tags: formData.get("tags")?.toString(), published: formData.get("published") === "on", image: formData.get("image"), // Keep image separate for validation }; const imageFile = rawFormData.image; const validatedFields = PostSchema.safeParse({ ...rawFormData, // Spread raw data (excluding image) // Pass the file object only if it's a File instance and has size > 0 image: imageFile instanceof File && imageFile.size > 0 ? imageFile : undefined, }); if (!validatedFields.success) { console.error( "Validation Errors:", validatedFields.error.flatten().fieldErrors ); // Return previous input along with errors return { errors: validatedFields.error.flatten().fieldErrors, message: "Failed to create post due to validation errors.", previousInput: { // Exclude image file title: rawFormData.title, slug: rawFormData.slug, content: rawFormData.content, excerpt: rawFormData.excerpt, tags: rawFormData.tags, published: rawFormData.published, }, }; } const { title, slug, content, excerpt, image, published, tags } = validatedFields.data; // --- Handle Image Upload --- let uploadedImageUrl: string | null = null; if (image) { uploadedImageUrl = await uploadImageToMinio(image); if (!uploadedImageUrl) { // Return previous input on image upload failure return { errors: { image: ["Failed to upload image."] }, message: null, previousInput: { title: rawFormData.title, slug: rawFormData.slug, content: rawFormData.content, excerpt: rawFormData.excerpt, tags: rawFormData.tags, published: rawFormData.published, }, }; } } // --- End Image Upload --- // Check if slug is unique const existingPost = await prisma.post.findUnique({ where: { slug } }); if (existingPost) { // Return previous input on slug error return { errors: { slug: ["Slug already exists."] }, message: null, previousInput: { title: rawFormData.title, slug: rawFormData.slug, // Keep the problematic slug to show the user content: rawFormData.content, excerpt: rawFormData.excerpt, tags: rawFormData.tags, published: rawFormData.published, }, }; } // --- Handle Tags --- // Example: Split comma-separated tags and connect/create them const tagConnections = tags ? await Promise.all( tags .split(",") .map((tag) => tag.trim()) .filter(Boolean) .map((tagName) => prisma.tag.upsert({ where: { name: tagName }, update: {}, create: { name: tagName }, }) ) ) : []; // --- End Handle Tags --- try { await prisma.post.create({ data: { title, slug, content, excerpt: excerpt || content.substring(0, 150) + "...", imageUrl: uploadedImageUrl, // Store the MinIO URL published: published ?? false, authorId: session.user.id, tags: { // Connect tags connect: tagConnections.map((tag) => ({ id: tag.id })), }, }, }); } catch (error) { console.error("Database Error:", error); // Return previous input on database error return { errors: { _form: ["Database Error: Failed to create post."] }, message: null, previousInput: { title: rawFormData.title, slug: rawFormData.slug, content: rawFormData.content, excerpt: rawFormData.excerpt, tags: rawFormData.tags, published: rawFormData.published, }, }; } revalidatePath("/tech-talk"); redirect(`/tech-talk/${slug}`); }