User Profile Settings Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a user profile settings page allowing username changes, avatar uploads, and account deletion with proper cascade handling.

Architecture: Server-component page with three client-component forms, new tRPC endpoints for user mutations, Next.js API route for avatar upload with image processing, R2 storage with users/{userId}/ prefix, Inngest background cleanup.

Tech Stack: Next.js 14 App Router, tRPC, Prisma, NextAuth, Sharp (image processing), AWS S3 SDK (R2), Inngest


Task 1: Install Dependencies

Files:

  • Modify: package.json

Step 1: Install sharp for image processing

npm install sharp
npm install @types/sharp --save-dev

Step 2: Verify installation

Run: npm list sharp Expected: Should show [email protected] installed

Step 3: Commit

git add package.json package-lock.json
git commit -m "deps: add sharp for image processing"

Task 2: Create Image Processing Utility

Files:

  • Create: lib/image-processor.ts

Step 1: Create the utility file

Create lib/image-processor.ts:

import sharp from "sharp";

/**
 * Process and optimize avatar image
 * - Resize to 512x512 (cover fit)
 * - Convert to WebP format
 * - Optimize quality
 */
export async function processAvatar(buffer: Buffer): Promise<Buffer> {
  return sharp(buffer)
    .resize(512, 512, {
      fit: "cover",
      position: "center",
    })
    .webp({ quality: 85 })
    .toBuffer();
}

/**
 * Validate image buffer by checking magic bytes
 */
export function validateImageBuffer(buffer: Buffer): boolean {
  const magicNumbers = {
    jpg: [0xff, 0xd8, 0xff],
    png: [0x89, 0x50, 0x4e, 0x47],
    gif: [0x47, 0x49, 0x46],
    webp: [0x52, 0x49, 0x46, 0x46], // RIFF
  };

  return Object.values(magicNumbers).some((magic) =>
    magic.every((byte, i) => buffer[i] === byte)
  );
}

Step 2: Test that TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add lib/image-processor.ts
git commit -m "feat: add image processing utility for avatars"

Task 3: Extend R2 Content Store with User Asset Functions

Files:

  • Modify: lib/content-store.ts

Step 1: Add uploadUserAsset function

Add to the end of lib/content-store.ts (before final export if needed):

/**
 * Upload user asset (avatar, etc.) to R2
 */
export const uploadUserAsset = async ({
  userId,
  assetType,
  content,
  contentType,
}: {
  userId: string;
  assetType: "avatar";
  content: Buffer;
  contentType: string;
}) => {
  const extension = contentType.split("/")[1] || "webp";
  const key = `users/${userId}/${assetType}.${extension}`;

  return uploadS3Object({
    key,
    file: content,
    contentType,
  });
};

/**
 * Delete all user assets from R2
 */
export const deleteUserAssets = async (userId: string) => {
  const s3Client = getS3Client();
  const bucketName = getBucketName();

  // List all objects with prefix users/{userId}/
  const listCommand = new ListObjectsV2Command({
    Bucket: bucketName,
    Prefix: `users/${userId}/`,
  });

  const objects = await s3Client.send(listCommand);

  if (objects.Contents && objects.Contents.length > 0) {
    const deleteCommand = new DeleteObjectsCommand({
      Bucket: bucketName,
      Delete: {
        Objects: objects.Contents.map((obj) => ({ Key: obj.Key! })),
      },
    });

    await s3Client.send(deleteCommand);
  }
};

Step 2: Test TypeScript compilation

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add lib/content-store.ts
git commit -m "feat: add user asset upload/delete functions for R2"

Task 4: Add User Router Endpoints (Part 1 - Update Username)

Files:

  • Modify: server/api/routers/user.ts

Step 1: Add updateUsername mutation

Find the user router exports and add this mutation:

updateUsername: protectedProcedure
  .input(
    z.object({
      username: z
        .string()
        .min(3, "Username must be at least 3 characters")
        .max(30, "Username must be at most 30 characters")
        .regex(
          /^[a-zA-Z0-9_-]+$/,
          "Username can only contain letters, numbers, hyphens, and underscores"
        ),
    })
  )
  .mutation(async ({ ctx, input }) => {
    // Check if username is already taken
    const existing = await ctx.db.user.findUnique({
      where: { username: input.username },
    });

    if (existing && existing.id !== ctx.session.user.id) {
      throw new TRPCError({
        code: "CONFLICT",
        message: "Username already taken",
      });
    }

    // Update username
    return ctx.db.user.update({
      where: { id: ctx.session.user.id },
      data: { username: input.username },
    });
  }),

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Test the dev server starts

Run: npm run dev Expected: Server starts without errors (then stop it with Ctrl+C)

Step 4: Commit

git add server/api/routers/user.ts
git commit -m "feat: add updateUsername mutation to user router"

Task 5: Add User Router Endpoints (Part 2 - Account Stats)

Files:

  • Modify: server/api/routers/user.ts

Step 1: Add getAccountStats query

Add this query to the user router:

getAccountStats: protectedProcedure.query(async ({ ctx }) => {
  const userId = ctx.session.user.id;

  // Get count of owned publications
  const publicationsCount = await ctx.db.publication.count({
    where: { ownerId: userId },
  });

  // Get count of sites in owned publications
  const ownedSitesCount = await ctx.db.site.count({
    where: {
      publication: {
        ownerId: userId,
      },
    },
  });

  // Get count of sites user contributed to (but doesn't own)
  const contributedSitesCount = await ctx.db.siteAuthor.count({
    where: {
      userId,
      site: {
        publication: {
          ownerId: { not: userId },
        },
      },
    },
  });

  return {
    publicationsCount,
    ownedSitesCount,
    contributedSitesCount,
  };
}),

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add server/api/routers/user.ts
git commit -m "feat: add getAccountStats query to user router"

Task 6: Add User Router Endpoints (Part 3 - Delete Account)

Files:

  • Modify: server/api/routers/user.ts

Step 1: Import deleteProject from content-store

Add to imports at top of server/api/routers/user.ts:

import { deleteProject } from "@/lib/content-store";
import { inngest } from "@/inngest/client";

Step 2: Add deleteAccount mutation

Add this mutation to the user router:

deleteAccount: protectedProcedure
  .input(
    z.object({
      confirmUsername: z.string(),
    })
  )
  .mutation(async ({ ctx, input }) => {
    const userId = ctx.session.user.id;

    const user = await ctx.db.user.findUnique({
      where: { id: userId },
      select: {
        username: true,
        publications: {
          select: {
            id: true,
            sites: {
              select: { id: true },
            },
          },
        },
      },
    });

    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }

    if (user.username !== input.confirmUsername) {
      throw new TRPCError({
        code: "BAD_REQUEST",
        message: "Username confirmation does not match",
      });
    }

    // Collect all site IDs that will be deleted
    const siteIds = user.publications.flatMap((pub) =>
      pub.sites.map((site) => site.id)
    );

    // Use transaction to ensure atomicity
    await ctx.db.$transaction(async (tx) => {
      // 1. Delete all R2 content for owned sites
      for (const siteId of siteIds) {
        await deleteProject(siteId);
      }

      // 2. Delete all owned publications (cascades to sites, site authors, subscriptions)
      await tx.publication.deleteMany({
        where: { ownerId: userId },
      });

      // 3. Delete user (cascades to: siteAuthors, sessions, accounts, likes)
      await tx.user.delete({
        where: { id: userId },
      });
    });

    // 4. Schedule background cleanup for user assets (avatar, etc.)
    await inngest.send({
      name: "user/cleanup-assets",
      data: { userId },
    });

    return { success: true };
  }),

Step 3: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 4: Commit

git add server/api/routers/user.ts
git commit -m "feat: add deleteAccount mutation with cascade logic"

Task 7: Create Avatar Upload API Route

Files:

  • Create: app/api/upload/avatar/route.ts

Step 1: Create the API route file

Create app/api/upload/avatar/route.ts:

import { getServerAuthSession } from "@/server/auth";
import { uploadUserAsset } from "@/lib/content-store";
import { processAvatar } from "@/lib/image-processor";
import { db } from "@/server/db";
import { env } from "@/env.mjs";

export const runtime = "nodejs";
export const maxDuration = 30; // 30 seconds max

const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];

export async function POST(request: Request) {
  const session = await getServerAuthSession();

  if (!session?.user) {
    return new Response("Unauthorized", { status: 401 });
  }

  try {
    const formData = await request.formData();
    const file = formData.get("file") as File;

    // Validate file exists
    if (!file) {
      return new Response("No file provided", { status: 400 });
    }

    // Validate file type
    if (!ALLOWED_TYPES.includes(file.type)) {
      return new Response("Invalid file type", { status: 400 });
    }

    // Validate file size
    if (file.size > MAX_SIZE) {
      return new Response("File too large", { status: 400 });
    }

    // Convert to buffer
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    // Process image (resize, convert to webp)
    const processedBuffer = await processAvatar(buffer);

    // Upload to R2
    await uploadUserAsset({
      userId: session.user.id,
      assetType: "avatar",
      content: processedBuffer,
      contentType: "image/webp",
    });

    // Generate public URL
    const protocol =
      process.env.NODE_ENV === "development" ? "http://" : "https://";
    const imageUrl = `${protocol}${env.NEXT_PUBLIC_S3_BUCKET_DOMAIN}/users/${session.user.id}/avatar.webp`;

    // Update user record
    await db.user.update({
      where: { id: session.user.id },
      data: { image: imageUrl },
    });

    return Response.json({ imageUrl });
  } catch (error) {
    console.error("Avatar upload error:", error);
    return new Response("Upload failed", { status: 500 });
  }
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add app/api/upload/avatar/route.ts
git commit -m "feat: add avatar upload API route with image processing"

Task 8: Create Inngest Cleanup Function

Files:

  • Modify: inngest/functions.ts

Step 1: Import deleteUserAssets

Add to imports at top of inngest/functions.ts:

import { deleteUserAssets } from "@/lib/content-store";

Step 2: Add cleanup function

Add this function to the file (and add to exports array at bottom):

export const cleanupUserAssets = inngest.createFunction(
  { id: "cleanup-user-assets" },
  { event: "user/cleanup-assets" },
  async ({ event }) => {
    const { userId } = event.data;

    try {
      await deleteUserAssets(userId);
      console.log(`✅ Cleaned up assets for user ${userId}`);
    } catch (error) {
      console.error(`❌ Failed to cleanup assets for user ${userId}:`, error);
      throw error; // Let Inngest retry
    }
  }
);

Step 3: Add to exports array

Find the export statement and add cleanupUserAssets:

export const functions = [
  // ... existing functions
  cleanupUserAssets,
];

Step 4: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 5: Commit

git add inngest/functions.ts
git commit -m "feat: add Inngest function for user asset cleanup"

Task 9: Create Username Form Component

Files:

  • Create: app/dashboard/profile/_components/username-form.tsx

Step 1: Create directory structure

mkdir -p app/dashboard/profile/_components

Step 2: Create username form component

Create app/dashboard/profile/_components/username-form.tsx:

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";

interface UsernameFormProps {
  currentUsername: string;
  userId: string;
}

export default function UsernameForm({
  currentUsername,
}: UsernameFormProps) {
  const [username, setUsername] = useState(currentUsername);
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  const updateMutation = api.user.updateUsername.useMutation({
    onSuccess: () => {
      toast.success("Username updated successfully");
      router.refresh();
    },
    onError: (error) => {
      toast.error(error.message);
      setError(error.message);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);

    if (username === currentUsername) {
      setError("Username is unchanged");
      return;
    }

    updateMutation.mutate({ username });
  };

  const isValid =
    username.length >= 3 &&
    username.length <= 30 &&
    /^[a-zA-Z0-9_-]+$/.test(username);

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div className="space-y-2">
        <Label htmlFor="username">Username</Label>
        <Input
          id="username"
          type="text"
          value={username}
          onChange={(e) => {
            setUsername(e.target.value);
            setError(null);
          }}
          placeholder="your-username"
          disabled={updateMutation.isPending}
          className={error ? "border-destructive" : ""}
        />
        <p className="text-sm text-muted-foreground">
          3-30 characters, letters, numbers, hyphens, and underscores only
        </p>
        {error && <p className="text-sm text-destructive">{error}</p>}
      </div>

      <Button
        type="submit"
        disabled={
          !isValid ||
          username === currentUsername ||
          updateMutation.isPending
        }
      >
        {updateMutation.isPending ? "Saving..." : "Save Username"}
      </Button>
    </form>
  );
}

Step 3: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 4: Commit

git add app/dashboard/profile/_components/username-form.tsx
git commit -m "feat: add username form component"

Task 10: Create Avatar Form Component

Files:

  • Create: app/dashboard/profile/_components/avatar-form.tsx

Step 1: Create avatar form component

Create app/dashboard/profile/_components/avatar-form.tsx:

"use client";

import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Upload, X } from "lucide-react";

interface AvatarFormProps {
  currentImage: string | null;
  userId: string;
}

const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];

export default function AvatarForm({ currentImage }: AvatarFormProps) {
  const [preview, setPreview] = useState<string | null>(currentImage);
  const [uploading, setUploading] = useState(false);
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const router = useRouter();

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // Validate file type
    if (!ALLOWED_TYPES.includes(file.type)) {
      toast.error(
        "Please select a valid image file (JPG, PNG, GIF, or WebP)"
      );
      return;
    }

    // Validate file size
    if (file.size > MAX_SIZE) {
      toast.error("File size must be less than 5MB");
      return;
    }

    setSelectedFile(file);

    // Create preview
    const reader = new FileReader();
    reader.onloadend = () => {
      setPreview(reader.result as string);
    };
    reader.readAsDataURL(file);
  };

  const handleUpload = async () => {
    if (!selectedFile) return;

    setUploading(true);
    try {
      const formData = new FormData();
      formData.append("file", selectedFile);

      const response = await fetch("/api/upload/avatar", {
        method: "POST",
        body: formData,
      });

      if (!response.ok) {
        throw new Error("Upload failed");
      }

      const data = await response.json();

      toast.success("Avatar updated successfully");
      setPreview(data.imageUrl);
      setSelectedFile(null);
      router.refresh();
    } catch (error) {
      toast.error("Failed to upload avatar");
      console.error(error);
    } finally {
      setUploading(false);
    }
  };

  const handleCancel = () => {
    setPreview(currentImage);
    setSelectedFile(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
    }
  };

  return (
    <div className="space-y-4">
      <div className="flex items-start gap-6">
        {/* Avatar Preview */}
        <div className="relative h-32 w-32 overflow-hidden rounded-full border-2 border-border bg-muted">
          {preview ? (
            <Image
              src={preview}
              alt="Avatar preview"
              fill
              className="object-cover"
            />
          ) : (
            <div className="flex h-full w-full items-center justify-center text-muted-foreground">
              No avatar
            </div>
          )}
        </div>

        {/* Upload Controls */}
        <div className="flex-1 space-y-4">
          <div>
            <Label htmlFor="avatar-upload">Upload new avatar</Label>
            <p className="text-sm text-muted-foreground mb-2">
              JPG, PNG, GIF or WebP. Max 5MB. Will be converted to WebP and
              resized to 512x512.
            </p>
            <input
              ref={fileInputRef}
              id="avatar-upload"
              type="file"
              accept={ALLOWED_TYPES.join(",")}
              onChange={handleFileSelect}
              disabled={uploading}
              className="hidden"
            />
            <Button
              type="button"
              variant="outline"
              onClick={() => fileInputRef.current?.click()}
              disabled={uploading}
            >
              <Upload className="mr-2 h-4 w-4" />
              Choose File
            </Button>
          </div>

          {selectedFile && (
            <div className="flex gap-2">
              <Button onClick={handleUpload} disabled={uploading}>
                {uploading ? "Uploading..." : "Save Avatar"}
              </Button>
              <Button
                type="button"
                variant="outline"
                onClick={handleCancel}
                disabled={uploading}
              >
                <X className="mr-2 h-4 w-4" />
                Cancel
              </Button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add app/dashboard/profile/_components/avatar-form.tsx
git commit -m "feat: add avatar form component with upload"

Task 11: Create Delete Account Form Component

Files:

  • Create: app/dashboard/profile/_components/delete-account-form.tsx

Step 1: Create delete account form component

Create app/dashboard/profile/_components/delete-account-form.tsx:

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";

interface DeleteAccountFormProps {
  username: string;
  userId: string;
}

export default function DeleteAccountForm({
  username,
}: DeleteAccountFormProps) {
  const [open, setOpen] = useState(false);
  const [confirmUsername, setConfirmUsername] = useState("");
  const router = useRouter();

  // Fetch stats about what will be deleted
  const { data: accountStats } = api.user.getAccountStats.useQuery(undefined, {
    enabled: open,
  });

  const deleteMutation = api.user.deleteAccount.useMutation({
    onSuccess: async () => {
      toast.success("Account deleted successfully");
      // Sign out and redirect to home
      await signOut({ callbackUrl: "/" });
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });

  const handleDelete = () => {
    if (confirmUsername !== username) {
      toast.error("Username does not match");
      return;
    }

    deleteMutation.mutate({ confirmUsername });
  };

  const isValid = confirmUsername === username;

  return (
    <div className="space-y-4">
      <Alert variant="destructive">
        <AlertTriangle className="h-4 w-4" />
        <AlertDescription>
          Deleting your account is permanent and cannot be undone. All
          publications you own and their posts will be deleted. Your
          contributions to other publications will remain but without author
          attribution.
        </AlertDescription>
      </Alert>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogTrigger asChild>
          <Button variant="destructive">Delete Account</Button>
        </DialogTrigger>

        <DialogContent className="sm:max-w-[500px]">
          <DialogHeader>
            <DialogTitle>Delete Account</DialogTitle>
            <DialogDescription>
              This action cannot be undone. This will permanently delete your
              account.
            </DialogDescription>
          </DialogHeader>

          <div className="space-y-4 py-4">
            {/* Show what will be deleted */}
            {accountStats && (
              <Alert>
                <AlertDescription>
                  <p className="font-semibold mb-2">This will delete:</p>
                  <ul className="list-disc list-inside space-y-1 text-sm">
                    <li>{accountStats.publicationsCount} publication(s) you own</li>
                    <li>
                      {accountStats.ownedSitesCount} post(s) in those publications
                    </li>
                    <li>Your profile and avatar</li>
                    <li>All your sessions and connected accounts</li>
                  </ul>
                  {accountStats.contributedSitesCount > 0 && (
                    <p className="mt-2 text-sm text-muted-foreground">
                      Note: You are a contributor on{" "}
                      {accountStats.contributedSitesCount} other post(s). Your
                      authorship will be removed but the posts will remain.
                    </p>
                  )}
                </AlertDescription>
              </Alert>
            )}

            {/* Confirmation input */}
            <div className="space-y-2">
              <Label htmlFor="confirm-username">
                Type <span className="font-mono font-semibold">{username}</span>{" "}
                to confirm
              </Label>
              <Input
                id="confirm-username"
                type="text"
                value={confirmUsername}
                onChange={(e) => setConfirmUsername(e.target.value)}
                placeholder="Enter your username"
                disabled={deleteMutation.isPending}
                autoComplete="off"
              />
            </div>
          </div>

          <DialogFooter>
            <Button
              variant="outline"
              onClick={() => {
                setOpen(false);
                setConfirmUsername("");
              }}
              disabled={deleteMutation.isPending}
            >
              Cancel
            </Button>
            <Button
              variant="destructive"
              onClick={handleDelete}
              disabled={!isValid || deleteMutation.isPending}
            >
              {deleteMutation.isPending ? "Deleting..." : "Delete Account"}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add app/dashboard/profile/_components/delete-account-form.tsx
git commit -m "feat: add delete account form with confirmation"

Task 12: Create Profile Settings Page

Files:

  • Create: app/dashboard/profile/page.tsx

Step 1: Create profile page

Create app/dashboard/profile/page.tsx:

import { getServerAuthSession } from "@/server/auth";
import { redirect } from "next/navigation";
import UsernameForm from "./_components/username-form";
import AvatarForm from "./_components/avatar-form";
import DeleteAccountForm from "./_components/delete-account-form";

export default async function ProfilePage() {
  const session = await getServerAuthSession();

  if (!session?.user) {
    redirect("/api/auth/signin");
  }

  return (
    <div className="mx-auto max-w-4xl space-y-8 p-6">
      {/* Page Header */}
      <div>
        <h1 className="text-3xl font-bold">Profile Settings</h1>
        <p className="mt-2 text-muted-foreground">
          Manage your account settings and preferences
        </p>
      </div>

      {/* Profile Information Section */}
      <section className="rounded-lg border bg-card p-6">
        <h2 className="text-xl font-semibold mb-4">Profile Information</h2>
        <UsernameForm
          currentUsername={session.user.username || ""}
          userId={session.user.id}
        />
      </section>

      {/* Avatar Section */}
      <section className="rounded-lg border bg-card p-6">
        <h2 className="text-xl font-semibold mb-4">Avatar</h2>
        <AvatarForm
          currentImage={session.user.image || null}
          userId={session.user.id}
        />
      </section>

      {/* Danger Zone Section */}
      <section className="rounded-lg border border-destructive bg-card p-6">
        <h2 className="text-xl font-semibold text-destructive mb-4">
          Danger Zone
        </h2>
        <DeleteAccountForm
          username={session.user.username || ""}
          userId={session.user.id}
        />
      </section>
    </div>
  );
}

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Test dev server

Run: npm run dev Expected: Server starts, navigate to http://localhost:3000/dashboard/profile (should require login)

Step 4: Commit

git add app/dashboard/profile/page.tsx
git commit -m "feat: add profile settings page with all sections"

Task 13: Update User Dropdown Navigation

Files:

  • Modify: components/user-dropdown.tsx

Step 1: Add Profile Settings link

Find the menu items in components/user-dropdown.tsx and add the profile settings link after "My Subscriptions":

<MenuItem>
  <Link
    href="/dashboard/subscriptions"
    className="block px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:outline-none"
  >
    My Subscriptions
  </Link>
</MenuItem>
<MenuItem>
  <Link
    href="/dashboard/profile"
    className="block px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:outline-none"
  >
    Profile Settings
  </Link>
</MenuItem>
<MenuItem>
  <button
    onClick={() => {
      posthog.reset();
      signOut();
    }}
    className="block w-full px-4 py-2 text-left text-sm text-gray-700 data-[focus]:bg-gray-100 data-[focus]:outline-none"
  >
    Sign out
  </button>
</MenuItem>

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit Expected: No errors

Step 3: Test navigation

Run: npm run dev

  • Navigate to dashboard
  • Click user avatar dropdown
  • Verify "Profile Settings" link appears
  • Click it and verify you reach /dashboard/profile

Step 4: Commit

git add components/user-dropdown.tsx
git commit -m "feat: add profile settings link to user dropdown"

Task 14: Manual Testing - Username Update

Step 1: Start dev server

Run: npm run dev

Step 2: Test username update flow

  1. Navigate to http://localhost:3000/dashboard/profile
  2. Change username to a new valid value
  3. Click "Save Username"
  4. Verify: Success toast appears
  5. Verify: Page refreshes with new username

Step 3: Test username validation

  1. Try username with special characters (should show error)
  2. Try username < 3 characters (button disabled)
  3. Try username > 30 characters (button disabled)
  4. Try duplicate username if possible (should show "already taken")

Step 4: Check database

Run: npx prisma studio

  • Open Users table
  • Verify username was updated

Step 5: Document any issues

If any issues found, fix them before proceeding.


Task 15: Manual Testing - Avatar Upload

Step 1: Test avatar upload flow

  1. Navigate to http://localhost:3000/dashboard/profile
  2. Click "Choose File"
  3. Select a JPG image < 5MB
  4. Verify: Preview appears
  5. Click "Save Avatar"
  6. Verify: Success toast appears
  7. Verify: Avatar updates in dropdown and profile page

Step 2: Test file validation

  1. Try uploading file > 5MB (should show error toast)
  2. Try uploading non-image file (should show error toast)
  3. Try uploading PNG, GIF, WebP (all should work)

Step 3: Check R2 storage

  • If using MinIO locally: http://localhost:9000
  • Look for users/{userId}/avatar.webp
  • Verify file exists and is WebP format

Step 4: Check database

Run: npx prisma studio

  • Open Users table
  • Verify image field contains correct R2 URL

Step 5: Test cancel

  1. Select new file (preview appears)
  2. Click "Cancel"
  3. Verify: Preview reverts to current avatar

Task 16: Manual Testing - Account Deletion

Step 1: Create test user with data

  1. Sign in as test user
  2. Create 1-2 publications
  3. Add some posts to those publications
  4. Contribute to another user's publication (if possible)

Step 2: Test deletion flow

  1. Navigate to http://localhost:3000/dashboard/profile
  2. Scroll to "Danger Zone"
  3. Click "Delete Account"
  4. Verify: Modal opens
  5. Verify: Stats show correct counts
  6. Type incorrect username (verify button stays disabled)
  7. Type correct username (verify button enables)
  8. Click "Delete Account"
  9. Verify: Success toast appears
  10. Verify: Redirected to home page (signed out)

Step 3: Verify database cleanup

Run: npx prisma studio

  • Verify user deleted
  • Verify publications deleted
  • Verify sites deleted
  • Verify SiteAuthor entries deleted

Step 4: Verify R2 cleanup (may take a few seconds)

  • Check MinIO or R2 console
  • Verify users/{userId}/ folder deleted (background job)
  • Verify site content folders deleted

Step 5: Check Inngest logs

  • Navigate to Inngest dashboard (local or cloud)
  • Verify "user/cleanup-assets" event processed successfully

Task 17: Error Handling & Edge Cases

Step 1: Test network failures

  1. Open browser DevTools → Network tab
  2. Set throttling to "Offline"
  3. Try uploading avatar (should show error toast)
  4. Try updating username (should show error toast)

Step 2: Test concurrent operations

  1. Start avatar upload (don't wait for completion)
  2. Immediately try username update (both should complete)

Step 3: Test with missing avatar

  1. In database, set image to null for your user
  2. Visit profile page
  3. Verify: Shows "No avatar" placeholder
  4. Upload avatar (should work normally)

Step 4: Test with long processing time

  1. Upload very large image (close to 5MB)
  2. Verify: Loading state shows
  3. Verify: Upload completes successfully

Step 5: Document any issues found

Fix critical issues before final commit.


Task 18: Final Verification & Build

Step 1: Run TypeScript check

Run: npx tsc --noEmit Expected: No errors

Step 2: Run linter

Run: npm run lint Expected: No errors (or fix any that appear)

Step 3: Test production build

Run: npm run build Expected: Build succeeds without errors

Step 4: Test production mode

Run: npm run start

  • Navigate through profile settings
  • Test all three features (username, avatar, delete)
  • Verify everything works in production mode

Step 5: Stop server

Press Ctrl+C to stop the production server


Task 19: Final Commit & Documentation

Step 1: Review all changes

Run: git status Expected: All files added/modified properly

Step 2: Create final commit

git add -A
git commit -m "feat: complete user profile settings page

- Add username update with validation
- Add avatar upload with image processing
- Add account deletion with cascade logic
- Add navigation integration
- Include R2 storage setup for user assets
- Add Inngest background cleanup job"

Step 3: Verify commit

Run: git log -1 --stat Expected: Shows comprehensive commit with all files

Step 4: Update design document status

Edit docs/plans/2026-02-11-user-profile-settings-design.md:

Change first line to:

**Status**: ✅ Implemented

Step 5: Final commit for docs

git add docs/plans/2026-02-11-user-profile-settings-design.md
git commit -m "docs: mark profile settings design as implemented"

Completion Checklist

  • Dependencies installed (sharp)
  • Image processing utility created
  • R2 content store extended
  • User router endpoints added (username, stats, delete)
  • Avatar upload API route created
  • Inngest cleanup function added
  • Username form component created
  • Avatar form component created
  • Delete account form component created
  • Profile settings page created
  • Navigation updated
  • Username update tested
  • Avatar upload tested
  • Account deletion tested
  • Error handling verified
  • Production build successful
  • Documentation updated

Known Limitations & Future Work

  1. Rate Limiting: No rate limiting on upload/update endpoints (consider adding)
  2. Email Notifications: No email sent on account deletion (could add)
  3. Data Export: No GDPR data export before deletion (future enhancement)
  4. Avatar Gallery: Could add preset avatar selection
  5. Audit Log: Could track all username changes

See design document for full list of future enhancements.