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 the RATE_LIMITS lookup 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.configure does not accept an async upload handler out of the box — implement an addProseMirrorPlugins dropzone or a toolbar "Insert image" button that calls /api/uploads and inserts via editor.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:

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:

  1. Reassign authorPUT /api/posts/:id with authorId field. Only OWNER/ADMIN. The user must have a role of EDITOR or higher to be a valid assignee.

  2. Byline display — the existing handleListPosts already selects author: { id, name, email }. Add bio to the select so the post page can render a byline card. Add a "Posts by {author}" route at src/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

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse