Phase 3 CRM/CMS — Endpoint & Workflow Artifacts
Date: August 12, 2026
Status: Handoff specs (not yet wired to source)
Context: All snippets target the existing src/server/api.ts middleware router
and follow the established patterns (getSessionUser, parseJsonBody, json(),
prisma, SubscriberStatus, PostStatus).
1. PUT /api/users/:id — Profile edit
File: src/server/api.ts — add handler and route branch.
Handler:
type UpdateUserPayload = {
name?: string;
bio?: string | null;
website?: string | null;
location?: string | null;
avatar?: string | null; // URL returned from /api/uploads
};
async function handleUpdateUser(
req: MiddlewareRequest,
res: MiddlewareResponse,
) {
const session = await getSessionUser(req);
if (!session) {
json(res, 401, { ok: false, error: "Not authenticated." });
return;
}
const userId = (req.url || "").split("?")[0].split("/").pop() || "";
if (!userId) {
json(res, 400, { ok: false, error: "User ID is required." });
return;
}
// Self-edit OR admin override
const isSelf = session.id === userId;
const isAdmin = ["ADMIN", "OWNER"].includes(session.role);
if (!isSelf && !isAdmin) {
json(res, 403, { ok: false, error: "You can only edit your own profile." });
return;
}
const payload = await parseJsonBody<UpdateUserPayload>(
req as NodeJS.ReadableStream,
);
// Only OWNER can promote/demote; other fields are safe to self-edit.
const data: Prisma.UserUpdateInput = {};
if (payload.name !== undefined) {
const trimmed = payload.name.trim();
if (trimmed.length > 80) {
json(res, 400, { ok: false, error: "Name too long (max 80)." });
return;
}
data.name = trimmed || null;
}
if (payload.bio !== undefined) data.bio = payload.bio?.trim() || null;
if (payload.website !== undefined) {
const url = payload.website?.trim() || null;
if (url && !/^https?:\/\//i.test(url)) {
json(res, 400, { ok: false, error: "Website must start with http(s)://" });
return;
}
data.website = url;
}
if (payload.location !== undefined) {
data.location = payload.location?.trim().slice(0, 120) || null;
}
if (payload.avatar !== undefined) {
data.avatar = payload.avatar?.trim() || null;
}
const updated = await prisma.user.update({
where: { id: userId },
data,
select: {
id: true, email: true, name: true, bio: true, avatar: true,
website: true, location: true, role: true, status: true,
},
});
json(res, 200, { ok: true, user: updated });
}
Route branch (inside emailRouteHandler, near the /api/posts/:id PUT):
if (path.match(/^\/api\/users\/[^/]+$/) && req.method === "PUT") {
await handleUpdateUser(req, res);
return;
}
Rate limit entry (top of api.ts):
"/api/users/:id": { max: 20, windowMs: 60_000 }, // key by literal prefix; see note
Note: current rate limiter is keyed by exact path string. To rate-limit per
:id, change theRATE_LIMITSlookup to a regex match or add per-user limits — out of scope for the initial drop.
2. /profile/edit route — Reference
TanStack Router uses file-based routes. Create src/routes/profile.edit.tsx:
// src/routes/profile.edit.tsx
import React from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "@tanstack/react-router";
export default function ProfileEditPage() {
const { user, refresh } = useAuth();
const navigate = useNavigate();
const [name, setName] = React.useState(user?.name ?? "");
const [bio, setBio] = React.useState(user?.bio ?? "");
const [website, setWebsite] = React.useState(user?.website ?? "");
const [location, setLocation] = React.useState(user?.location ?? "");
const [avatar, setAvatar] = React.useState(user?.avatar ?? "");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
if (!user) {
return <p className="p-6">Please sign in to edit your profile.</p>;
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, bio, website, location, avatar }),
});
if (!res.ok) throw new Error((await res.json()).error || "Save failed");
await refresh();
navigate({ to: "/profile" });
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
return (
<section className="mx-auto max-w-2xl p-6 space-y-4">
<h1 className="text-2xl font-semibold">Edit profile</h1>
<form onSubmit={onSubmit} className="space-y-3">
<label className="block">
<span className="text-sm">Name</span>
<input value={name} onChange={(e) => setName(e.target.value)}
className="w-full rounded border bg-background p-2" />
</label>
<label className="block">
<span className="text-sm">Bio</span>
<textarea value={bio} onChange={(e) => setBio(e.target.value)}
rows={4} className="w-full rounded border bg-background p-2" />
</label>
<label className="block">
<span className="text-sm">Website</span>
<input value={website} onChange={(e) => setWebsite(e.target.value)}
placeholder="https://example.com"
className="w-full rounded border bg-background p-2" />
</label>
<label className="block">
<span className="text-sm">Location</span>
<input value={location} onChange={(e) => setLocation(e.target.value)}
className="w-full rounded border bg-background p-2" />
</label>
<label className="block">
<span className="text-sm">Avatar URL</span>
{/* When /api/uploads exists, replace with an uploader that fills this. */}
<input value={avatar} onChange={(e) => setAvatar(e.target.value)}
className="w-full rounded border bg-background p-2" />
</label>
{error && <p className="text-rose-400 text-sm">{error}</p>}
<button disabled={saving}
className="rounded bg-emerald-600 px-4 py-2 text-white">
{saving ? "Saving..." : "Save profile"}
</button>
</form>
</section>
);
}
Then add an "Edit" link on the existing ProfilePage
(src/routes/profile.tsx) pointing to /profile/edit.
TanStack Router auto-generates the route from filename via
TanStackRouterVite() — no manual route registration needed (matches the
existing LoginPage/RegisterPage pattern).
3. DELETE /api/subscribers/:id/unsubscribe — Public unsubscribe
Two routes cover the link flows. Mailgun already handles list-unsubscribe
webhooks, but the public link (/unsubscribe?token=...) needs an HTTP
endpoint so campaign templates can link to it.
File: src/server/api.ts — handler + route branch.
type UnsubscribePayload = {
token?: string; // secure token (use confirmToken, rotate on use)
email?: string; // alternative to id (for one-click links)
};
async function handleUnsubscribe(
req: MiddlewareRequest,
res: MiddlewareResponse,
) {
// Extract id from /api/subscribers/:id/unsubscribe
const m = (req.url || "").match(/^\/api\/subscribers\/([^/]+)\/unsubscribe$/);
const pathId = m ? m[1] : "";
const body = await parseJsonBody<UnsubscribePayload>(req as NodeJS.ReadableStream)
.catch(() => ({} as UnsubscribePayload));
// Public endpoint — no auth required. Two caller modes:
// (a) subscriber clicks link with id+token (one-click, RFC 8058)
// (b) admin calls with auth (handled by /api/admin/subscribers PATCH)
const id = pathId || body.email || "";
if (!id) {
json(res, 400, { ok: false, error: "Subscriber id or email required." });
return;
}
const where: Prisma.SubscriberWhereInput = id.includes("@")
? { email: id.toLowerCase() }
: { id };
// If a token was provided, require it to match confirmToken.
if (body.token) {
const probe = await prisma.subscriber.findFirst({
where, select: { id: true, confirmToken: true },
});
if (!probe || probe.confirmToken !== body.token) {
json(res, 403, { ok: false, error: "Invalid unsubscribe token." });
return;
}
}
await prisma.subscriber.updateMany({
where,
data: {
status: SubscriberStatus.UNSUBSCRIBED,
unsubscribedAt: new Date(),
},
});
json(res, 200, { ok: true });
}
Route branch (inside emailRouteHandler):
if (
path.match(/^\/api\/subscribers\/[^/]+\/unsubscribe$/) &&
req.method === "DELETE"
) {
await handleUnsubscribe(req, res);
return;
}
Frontend call (newsletter footer / email body):
<a href="https://fogserv.cloud/unsubscribe?id={{subscriberId}}&token={{token}}">
Unsubscribe
</a>
Companion route src/routes/unsubscribe.tsx (read query params, hit the
endpoint, render confirmation).
4. POST /api/uploads — Image upload (minimal)
Approach: Local filesystem under public/uploads/{yyyy}/{mm}/{cuid}.{ext},
URL returned to the client. Tiny handler using only Node built-ins so no new
deps. (Production migration to S3/R2 is a follow-up — not in scope for MVP.)
File: src/server/api.ts — add to imports:
import * as fs from "fs";
import * as path from "path";
import { randomUUID } from "node:crypto";
Handler:
const ALLOWED_MIME = new Set([
"image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml",
]);
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
async function handleUpload(
req: MiddlewareRequest,
res: MiddlewareResponse,
) {
// Require an authenticated user (any non-suspended role).
const user = await getSessionUser(req);
if (!user) {
json(res, 401, { ok: false, error: "Not authenticated." });
return;
}
if (user.status === "SUSPENDED" || user.status === "INACTIVE") {
json(res, 403, { ok: false, error: "Account cannot upload." });
return;
}
const contentType = (getHeader(req, "content-type") || "").toLowerCase();
if (!contentType.startsWith("multipart/form-data")) {
json(res, 400, { ok: false, error: "Expected multipart/form-data." });
return;
}
// Minimal multipart parser: extract first "file" field. The intent here
// is to avoid adding a new dep; replace with busboy/multer if more
// fields/streaming are needed.
const raw = await readRawBody(req as NodeJS.ReadableStream);
const match = raw.match(/name="file";\s*filename="[^"]+"\s*[\r\n]+Content-Type:\s*([^\r\n]+)/i);
const mime = match?.[1]?.trim().toLowerCase() || "";
if (!ALLOWED_MIME.has(mime)) {
json(res, 415, { ok: false, error: `Unsupported file type: ${mime}` });
return;
}
// Body sits between the two CRLF CRLF after headers and the closing boundary.
const headerEnd = raw.indexOf("\r\n\r\n");
const boundaryMatch = contentType.match(/boundary=(?:"|)([^";]+)/i);
const boundary = boundaryMatch?.[1];
if (headerEnd < 0 || !boundary) {
json(res, 400, { ok: false, error: "Malformed multipart body." });
return;
}
const fileStart = headerEnd + 4;
const fileEnd = raw.indexOf(`\r\n--${boundary}--`, fileStart);
if (fileEnd < 0) {
json(res, 400, { ok: false, error: "Multipart terminator not found." });
return;
}
const fileBuf = Buffer.from(raw.slice(fileStart, fileEnd), "binary");
if (fileBuf.length > MAX_BYTES) {
json(res, 413, { ok: false, error: "File exceeds 5 MB limit." });
return;
}
const ext = mime === "image/jpeg" ? "jpg"
: mime === "image/png" ? "png"
: mime === "image/webp" ? "webp"
: mime === "image/gif" ? "gif"
: "svg";
const now = new Date();
const dir = path.join(process.cwd(), "public", "uploads",
String(now.getUTCFullYear()),
String(now.getUTCMonth() + 1).padStart(2, "0"));
fs.mkdirSync(dir, { recursive: true });
const filename = `${randomUUID()}.${ext}`;
fs.writeFileSync(path.join(dir, filename), fileBuf);
const baseUrl = resolveAppBaseUrl(req);
const url = `${baseUrl}/uploads/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}/${filename}`;
json(res, 201, { ok: true, url, mime, bytes: fileBuf.length });
}
Route branch:
if (path === "/api/uploads" && req.method === "POST") {
await handleUpload(req, res);
return;
}
Rate limit entry:
"/api/uploads": { max: 30, windowMs: 60_000 },
TipTap wiring (client side): pass a custom upload handler to the TipTap
Image extension. From the existing RichTextEditor component:
Image.configure({
inline: false,
allowBase64: false,
// TipTap's image extension accepts a "request" hook for custom upload
}).extend({
addProseMirrorPlugins() {
return [/* placeholder: see kb/lessons-learned entry to add on next pass */];
},
});
Open follow-up: TipTap's
Image.configuredoes not accept an asyncuploadhandler out of the box — implement anaddProseMirrorPluginsdropzone or a toolbar "Insert image" button that calls/api/uploadsand inserts viaeditor.chain().focus().setImage({ src: url }).run().
.env.example additions:
# Image uploads (Phase 3)
UPLOAD_MAX_BYTES=5242880
UPLOAD_DIR=public/uploads
5. Draft / publish workflow — Wiring to existing Post model
The schema already has PostStatus { DRAFT | PUBLISHED | SCHEDULED | ARCHIVED }
and Post.publishedAt / Post.scheduledFor. No schema changes are required —
only endpoint tweaks and admin UI.
Status transition rules:
| From | To | Effect |
|---|---|---|
| DRAFT | PUBLISHED | set publishedAt = now() |
| DRAFT | SCHEDULED | set scheduledFor = <future> (cron or check picks it up) |
| SCHEDULED | PUBLISHED | set publishedAt = now(), clear scheduledFor |
| SCHEDULED | DRAFT | clear scheduledFor (cancel schedule) |
| any | ARCHIVED | no publishedAt change (kept for SEO) |
| ARCHIVED | DRAFT | restore editing |
Server-side state machine (extend handleUpdatePost):
if (payload.status) {
const transitions: Record<PostStatus, PostStatus[]> = {
DRAFT: [PostStatus.PUBLISHED, PostStatus.SCHEDULED, PostStatus.ARCHIVED],
PUBLISHED: [PostStatus.DRAFT, PostStatus.ARCHIVED],
SCHEDULED: [PostStatus.DRAFT, PostStatus.PUBLISHED, PostStatus.ARCHIVED],
ARCHIVED: [PostStatus.DRAFT, PostStatus.PUBLISHED],
};
if (!transitions[existing.status].includes(payload.status)) {
json(res, 400, {
ok: false,
error: `Cannot transition from ${existing.status} to ${payload.status}.`,
});
return;
}
updateData.status = payload.status;
if (payload.status === PostStatus.PUBLISHED &&
existing.status !== PostStatus.PUBLISHED) {
updateData.publishedAt = new Date();
}
if (payload.status === PostStatus.SCHEDULED) {
if (!payload.scheduledFor) {
json(res, 400, { ok: false, error: "scheduledFor required." });
return;
}
updateData.scheduledFor = new Date(payload.scheduledFor);
}
if (payload.status !== PostStatus.SCHEDULED) {
updateData.scheduledFor = null;
}
}
Scheduling pick-up (cron / sidecar): add a lightweight job that runs every
minute and publishes any SCHEDULED posts whose scheduledFor <= now():
// scripts/publish-scheduled.ts (run via systemd timer or `bun run`)
const due = await prisma.post.findMany({
where: { status: "SCHEDULED", scheduledFor: { lte: new Date() } },
});
for (const post of due) {
await prisma.post.update({
where: { id: post.id },
data: { status: "PUBLISHED", publishedAt: new Date(), scheduledFor: null },
});
}
The current dev server is a single Vite process; the cron is a follow-up. For Phase 3 MVP, the
PostEditorPage"Save as scheduled" action writes the timestamp and the admin "Publish now" button is a manual override.
Admin UI (PostEditorPage): add a status dropdown
(Draft / Published / Scheduled) and a <input type="datetime-local"> shown
when "Scheduled" is selected. Reuse the existing PUT /api/posts/:id payload
shape — no new fields.
6. Brief notes — SEO, scheduling, tag/author management
6a. SEO metadata
Schema fields already present on Post: metaTitle, metaDescription,
ogImage, canonicalUrl, excerpt. UI gap only.
Server: add to handleUpdatePost's accepted payload:
type SeoPayload = {
metaTitle?: string;
metaDescription?: string;
ogImage?: string; // URL from /api/uploads
canonicalUrl?: string;
excerpt?: string; // already in UpdatePostPayload
};
Add validation (metaDescription max 160 chars; ogImage must be a URL).
Renderer (blog post page): inject into the head of the post route:
<>
<title>{post.metaTitle ?? post.title}</title>
<meta name="description" content={post.metaDescription ?? post.excerpt ?? ""} />
{post.canonicalUrl && <link rel="canonical" href={post.canonicalUrl} />}
<meta property="og:title" content={post.metaTitle ?? post.title} />
<meta property="og:description" content={post.metaDescription ?? ""} />
{post.ogImage && <meta property="og:image" content={post.ogImage} />}
<meta property="og:type" content="article" />
</>
Sitemap: add a GET /api/sitemap.xml (public) that selects
status = PUBLISHED posts and emits <urlset> entries. Mount via
src/routes/sitemap[.]xml.ts if using file routes, or document a Caddy
redirect to the API.
6b. Post scheduling
Already covered in §5. The minimum viable surface is:
scheduledForalready onPost.- Status transition matrix in §5.
PostEditorPage"Schedule" branch shows a datetime picker and callsPUT /api/posts/:idwith{ status: "SCHEDULED", scheduledFor: <iso> }.- A scheduler (deferred) walks due rows and flips them to PUBLISHED.
Optional UX: a "Scheduled" tab in the admin PostsSection (already
supports ?all=1 — filter in the component to only show SCHEDULED).
6c. Tag / author management
Tag management
Schema (Tag, PostTag) is in place; create/upsert is already wired in
handleCreatePost and handleUpdatePost. Missing admin CRUD:
// Suggested endpoints — admin-only, route through existing auth pattern.
GET /api/admin/tags // list with post counts
POST /api/admin/tags // create
PUT /api/admin/tags/:id // rename, change color
DELETE /api/admin/tags/:id // cascade-deletes PostTag rows
Each handler mirrors the users admin block — ["OWNER","ADMIN"] role
guard, slug dedupe, JSON body parse. Add a Tag model includes
color (hex) — already in schema.
Author management
Post.authorId is the source of truth; User.role controls who can publish.
Two follow-ups:
Reassign author —
PUT /api/posts/:idwithauthorIdfield. Only OWNER/ADMIN. The user must have a role ofEDITORor higher to be a valid assignee.Byline display — the existing
handleListPostsalready selectsauthor: { id, name, email }. Addbioto the select so the post page can render a byline card. Add a "Posts by {author}" route atsrc/routes/authors.$id.tsx.
No schema changes needed for either. A single admin page (/admin/tags,
/admin/authors) can be added to the existing admin nav — pattern matches
the existing PostsSection / EmailSandboxCard cards in
AdminComponents.tsx.
Cross-cutting
- All new endpoints follow the existing
json()/getSessionUser/parseJsonBodypattern and the same role-guard style as thehandleUpdatePost/handleDeletePostexamples. Preview.allowedHostsinvite.config.tsalready permitsfogserv.cloudso the Caddy reverse-proxied preview server can serve uploads.- Add
public/uploads/to.dockerignoreif uploads are persisted on a volume (currently excluded withpublic/, which means the dir lives inside the container — acceptable for MVP, document a volume mount in the deploy runbook before production). - Each new handler should append a
try { … } catchbody — the existing outertry/catchinemailRouteHandleralready returns friendlydb is not initializedmessages.