All files / routes/pro/blogs lifecycle.routes.ts

98.78% Statements 81/82
94.44% Branches 51/54
100% Functions 5/5
98.78% Lines 81/82

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260                                                                          14x     1x     1x 5x 5x 5x   5x 1x     4x   4x 4x 1x       3x 1x       2x 1x     1x         1x 1x 1x             1x   4x         1x 5x 5x 5x   5x 1x     4x   4x 4x 1x     3x 1x       2x 1x     1x         1x 1x 1x             1x   4x         1x 13x 13x 13x   13x 1x     12x   12x 12x 1x     11x 1x       10x 1x       9x 1x           8x 13x 1x         7x     7x             6x         6x 5x                       6x         6x 6x         13x   6x 6x 6x 2x 2x             2x 1x           1x       2x                   13x         7x          
// Pro blog lifecycle endpoints — archive / unarchive / submit-for-publish.
// Split out of my-blogs.routes.ts to keep that file focused on CRUD and
// because the submit flow has a long tail of side effects (cover validation,
// word-count enforcement, edge-cache purge, pro-site rebuild trigger,
// author-attribution auto-add) that deserve their own home.
 
import { checkBlogWordLimit } from "@interioring/utils/validation/blog";
import { Hono } from "hono";
import type { Dal } from "../../../dal";
import type { DualCache } from "../../../lib/cache";
import { invalidateEntity } from "../../../lib/cache-invalidation";
import {
	ForbiddenError,
	NotFoundError,
	ValidationError,
} from "../../../lib/errors";
import { purgeEdgeCache } from "../../../lib/edge-cache";
import { logger } from "../../../lib/logger";
import { handleError, success } from "../../../lib/response";
import { generateId } from "../../../lib/utils";
import type { Services } from "../../../services";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		proId: string;
		proRole: string;
		cache: DualCache;
	};
};
 
/** Blog idea sources that indicate a pro created/owns the blog */
function isProOwnedBlog(ideaSource: string | null): boolean {
	return ideaSource === "pro_request" || ideaSource === "ai_suggestion";
}
 
const lifecycle = new Hono<Env>();
 
// Archive blog
lifecycle.post("/my-blogs/:id/archive", async (c) => {
	try {
		const dal = c.get("dal");
		const proId = c.get("proId");
 
		if (!proId) {
			throw new ForbiddenError("Pro ID required");
		}
 
		const blogId = c.req.param("id");
 
		const blog = await dal.blogs.findById(blogId);
		if (!blog) {
			throw new NotFoundError("Blog not found");
		}
 
		// Verify pro owns this blog (pro_request = manual, ai_suggestion = AI pipeline)
		if (blog.ideaSourceProId !== proId || !isProOwnedBlog(blog.ideaSource)) {
			throw new ForbiddenError("You do not have access to this blog");
		}
 
		// Only allow archiving published blogs
		if (blog.status !== "published") {
			throw new ValidationError("Can only archive published blogs");
		}
 
		const updated = await dal.blogs.update(blogId, {
			status: "archived",
		});
 
		// Archived blogs must disappear from the marketplace list and detail page.
		try {
			const cache = c.get("cache");
			c.executionCtx.waitUntil(
				invalidateEntity(cache, "blog", { slug: blog.slug }),
			);
		} catch {
			/* executionCtx unavailable in tests */
		}
 
		return success(c, updated);
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Unarchive blog (restore to published)
lifecycle.post("/my-blogs/:id/unarchive", async (c) => {
	try {
		const dal = c.get("dal");
		const proId = c.get("proId");
 
		if (!proId) {
			throw new ForbiddenError("Pro ID required");
		}
 
		const blogId = c.req.param("id");
 
		const blog = await dal.blogs.findById(blogId);
		if (!blog) {
			throw new NotFoundError("Blog not found");
		}
 
		if (blog.ideaSourceProId !== proId || !isProOwnedBlog(blog.ideaSource)) {
			throw new ForbiddenError("You do not have access to this blog");
		}
 
		// Only allow unarchiving archived blogs
		if (blog.status !== "archived") {
			throw new ValidationError("Can only unarchive archived blogs");
		}
 
		const updated = await dal.blogs.update(blogId, {
			status: "published",
		});
 
		// Re-published blogs must appear in the marketplace list and detail cache.
		try {
			const cache = c.get("cache");
			c.executionCtx.waitUntil(
				invalidateEntity(cache, "blog", { slug: blog.slug }),
			);
		} catch {
			/* executionCtx unavailable in tests */
		}
 
		return success(c, updated);
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Submit blog for approval — actually publishes directly (no approval queue)
lifecycle.post("/my-blogs/:id/submit", async (c) => {
	try {
		const dal = c.get("dal");
		const proId = c.get("proId");
 
		if (!proId) {
			throw new ForbiddenError("Pro ID required");
		}
 
		const blogId = c.req.param("id");
 
		const blog = await dal.blogs.findById(blogId);
		if (!blog) {
			throw new NotFoundError("Blog not found");
		}
 
		if (blog.ideaSourceProId !== proId || !isProOwnedBlog(blog.ideaSource)) {
			throw new ForbiddenError("You do not have access to this blog");
		}
 
		// Only allow submitting drafts
		if (blog.status !== "draft") {
			throw new ValidationError("Can only submit blogs in draft status");
		}
 
		// Cover image is required to publish
		if (!blog.featuredImageUrl) {
			throw new ValidationError(
				"A cover image is required to publish. Please add a cover image first.",
			);
		}
 
		// Enforce word count limit
		const wordCheck = checkBlogWordLimit(blog.content || "");
		if (!wordCheck.withinLimit) {
			throw new ValidationError(
				wordCheck.message ?? "Blog content exceeds the word limit.",
			);
		}
 
		const readTimeMinutes = Math.max(1, Math.round(wordCheck.words / 200));
 
		// Publish the blog directly (no approval process)
		const updated = await dal.blogs.update(blogId, {
			status: "published",
			publishedAt: new Date(),
			readTimeMinutes,
		});
 
		// Auto-add author attribution if not already present
		const existingPros = await dal.blogPros.findAll({
			blogId,
			proId,
			attributionType: "author",
		});
		if (existingPros.length === 0) {
			await dal.blogPros.create({
				id: generateId(),
				blogId,
				proId,
				attributionType: "author",
				approvalStatus: "approved",
				approvalRequestedAt: new Date(),
				approvedAt: new Date(),
			});
		}
 
		// Purge edge cache so the new blog appears in marketplace immediately
		c.executionCtx.waitUntil(
			purgeEdgeCache(`${c.env.BETTER_AUTH_URL}/api/marketplace/blogs`),
		);
 
		// Invalidate KV caches for the now-published blog.
		const cache = c.get("cache");
		c.executionCtx.waitUntil(
			invalidateEntity(cache, "blog", { slug: updated?.slug ?? blog.slug }),
		);
 
		// Trigger pro site rebuild if the pro has a published website
		c.executionCtx.waitUntil(
			(async () => {
				try {
					const website = await dal.proWebsites.findByProId(proId);
					if (website && website.status === "published") {
						const version = (website.publishedVersion || 0) + 1;
						const buildJob = await dal.websiteBuildJobs.createBuildJob({
							proWebsiteId: website.id,
							version,
							triggeredBy: proId,
							triggerType: "manual",
							status: "pending",
						});
						if (c.env.BUILD_QUEUE && buildJob) {
							await c.env.BUILD_QUEUE.send({
								jobId: buildJob.id,
								proId,
								proWebsiteId: website.id,
								projectName: website.pagesProjectName,
							});
							await dal.websiteBuildJobs.updateBuildJob(buildJob.id, {
								status: "queued",
							});
						}
						logger.info(
							`[Blog Publish] Triggered site rebuild for pro ${proId}`,
						);
					}
				} catch (err) {
					logger.error("[Blog Publish] Failed to trigger site rebuild:", err);
				}
			})(),
		);
 
		return success(c, {
			...updated,
			message: "Blog published successfully!",
		});
	} catch (err) {
		return handleError(c, err);
	}
});
 
export default lifecycle;