plans
2026-05-10
2026 05 10 Inquiry Follow Ups

Inquiry Follow-Ups — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the single adminNotes text field with a persistent timeline of text notes per inquiry. Any staff member can add a note; everyone sees the full history with author + timestamp.

Architecture:

  • New inquiryFollowUps table stores one row per note (inquiryId, authorId, authorName, content, createdAt)
  • adminNotes field on inquirySessions is removed in a later migration — for now it's unused
  • FollowUpTimeline component renders the note list + input form
  • InquiryDetailPanel swaps the old textarea for the new timeline component

Tech Stack: Next.js, Convex, Tailwind CSS v4, shadcn/ui, paraglide-js i18n


File Structure

packages/backend/convex/
├── schema.ts                           # MODIFIED: add inquiryFollowUps table
├── domains/inquiries.ts              # MODIFIED: add listFollowUps + createFollowUp

apps/frontend/
├── components/admin/
│   ├── follow-up-timeline.tsx         # NEW: timeline + note input
│   └── inquiry-detail-panel.tsx       # MODIFIED: replace textarea with timeline
└── messages/en.json                    # MODIFIED: add i18n keys

Task 1: Add inquiryFollowUps Table to Schema

Files:

  • Modify: packages/backend/convex/schema.ts

  • Step 1: Find the closing of inquirySessions table (line ~411) and add the new table after it

After the inquirySessions table closing }), before // Challenge configuration (line 417):

// Inquiry follow-ups (persistent notes per inquiry)
inquiryFollowUps: defineTable({
  inquiryId: v.id("inquirySessions"),
  authorId: v.string(), // Clerk user ID
  authorName: v.string(),
  content: v.string(),
  createdAt: v.number(),
})
  .index("by_inquiry", ["inquiryId"]),
  • Step 2: Validate schema

Run: cd packages/backend/convex && npx convex schema Expected: Schema validated successfully

  • Step 3: Commit
git add packages/backend/convex/schema.ts
git commit -m "feat(inquiry): add inquiryFollowUps table for persistent notes"

Task 2: Add Follow-Up Convex Queries and Mutations

Files:

  • Modify: packages/backend/convex/domains/inquiries.ts

  • Step 1: Add listFollowUps query and createFollowUp mutation

Append at the end of inquiries.ts (after ensureInquirySession, around line 152):

// List follow-ups for an inquiry (newest first)
export const listFollowUps = zQuery({
  args: { inquiryId: zid("inquirySessions") },
  handler: async (ctx, { inquiryId }) => {
    const followUps = await ctx.db
      .query("inquiryFollowUps")
      .withIndex("by_inquiry", (q) => q.eq("inquiryId", inquiryId))
      .collect();
 
    // Sort by createdAt ascending (oldest first — timeline order)
    followUps.sort((a, b) => a.createdAt - b.createdAt);
    return followUps;
  },
});
 
// Create a follow-up note
export const createFollowUp = zMutation({
  args: {
    inquiryId: zid("inquirySessions"),
    content: z.string().min(1).max(2000),
  },
  handler: async (ctx, { inquiryId, content }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");
 
    const now = Date.now();
    const followUpId = await ctx.db.insert("inquiryFollowUps", {
      inquiryId,
      authorId: identity.subject,
      authorName: identity.name ?? identity.email ?? "Staff",
      content,
      createdAt: now,
    });
 
    // Update inquiry updatedAt
    await ctx.db.patch(inquiryId, { updatedAt: now });
 
    return followUpId;
  },
});
  • Step 2: Regenerate Convex types

Run: cd packages/backend && npx convex codegen

  • Step 3: Commit
git add packages/backend/convex/domains/inquiries.ts packages/backend/convex/_generated/
git commit -m "feat(inquiry): add listFollowUps and createFollowUp mutations"

Task 3: Create FollowUpTimeline Component

Files:

  • Create: apps/frontend/components/admin/follow-up-timeline.tsx

  • Step 1: Write FollowUpTimeline

// apps/frontend/components/admin/follow-up-timeline.tsx
// SoC: Pure presentation — renders note timeline + input form
 
"use client";
 
import { useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { format } from "date-fns";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { Skeleton } from "~/components/ui/skeleton";
import { cn } from "~/lib/utils";
import { api } from "@packages/backend/convex/_generated/api";
import type { Id } from "@packages/backend/convex/_generated/dataModel";
import * as m from "~/src/paraglide/messages";
 
type FollowUp = {
  _id: string;
  authorId: string;
  authorName: string;
  content: string;
  createdAt: number;
};
 
interface FollowUpTimelineProps {
  inquiryId: string;
  onError?: (message: string) => void;
}
 
export function FollowUpTimeline({
  inquiryId,
  onError,
}: FollowUpTimelineProps) {
  const [draft, setDraft] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  const followUps = useQuery(api.domains.inquiries.listFollowUps, {
    inquiryId: inquiryId as Id<"inquirySessions">,
  }) as FollowUp[] | undefined;
 
  const createFollowUp = useMutation(api.domains.inquiries.createFollowUp);
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const content = draft.trim();
    if (!content) return;
 
    setIsSubmitting(true);
    try {
      await createFollowUp({
        inquiryId: inquiryId as Id<"inquirySessions">,
        content,
      });
      setDraft("");
    } catch (err) {
      onError?.("Failed to save note. Please try again.");
    } finally {
      setIsSubmitting(false);
    }
  };
 
  return (
    <div className="space-y-4">
      {/* Note input form */}
      <form onSubmit={handleSubmit} className="space-y-2">
        <Textarea
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder={
            (m.admin_inquiry_addNote as () => string)?.() ?? "Add a note..."
          }
          rows={2}
          className="resize-none"
          maxLength={2000}
        />
        <div className="flex justify-end">
          <Button
            type="submit"
            variant="gold"
            size="sm"
            disabled={!draft.trim() || isSubmitting}
          >
            {isSubmitting ? "Saving..." : "Add Note"}
          </Button>
        </div>
      </form>
 
      {/* Timeline */}
      <div className="space-y-0">
        {followUps === undefined ? (
          <div className="space-y-3">
            <Skeleton className="h-16 w-full" />
            <Skeleton className="h-12 w-full" />
          </div>
        ) : followUps.length === 0 ? (
          <p className="text-sm text-[var(--color-muted-foreground)] text-center py-4">
            {(m.admin_inquiry_noNotes as () => string)?.() ?? "No notes yet"}
          </p>
        ) : (
          <div className="relative">
            {/* Vertical line */}
            <div className="absolute left-4 top-0 bottom-0 w-px bg-[var(--color-border)]" />
 
            <div className="space-y-4 pl-8">
              {followUps.map((followUp) => (
                <div key={followUp._id} className="relative">
                  {/* Dot on timeline */}
                  <div className="absolute -left-5 top-1.5 w-2.5 h-2.5 rounded-full bg-[var(--color-gold)] ring-2 ring-[var(--color-background)]" />
 
                  <div className="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-3">
                    <div className="flex items-center justify-between mb-1">
                      <span className="text-xs font-medium text-[var(--color-gold)]">
                        {followUp.authorName}
                      </span>
                      <span className="text-xs text-[var(--color-muted-foreground)]">
                        {format(new Date(followUp.createdAt), "MMM d, h:mm a")}
                      </span>
                    </div>
                    <p className="text-sm text-[var(--color-foreground)] whitespace-pre-wrap">
                      {followUp.content}
                    </p>
                  </div>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}
  • Step 2: Commit
git add apps/frontend/components/admin/follow-up-timeline.tsx
git commit -m "feat(inquiry): add FollowUpTimeline component for persistent notes"

Task 4: Integrate Timeline into InquiryDetailPanel

Files:

  • Modify: apps/frontend/components/admin/inquiry-detail-panel.tsx

  • Step 1: Read the current file to find the notes section

The current file (line 270-299) has:

  • A <label> for "Admin Notes"
  • A <Textarea> bound to local notes state
  • A "Save Note" button that calls handleSaveNotes

We replace all of that with the FollowUpTimeline component.

  • Step 2: Replace the notes section

Find this block (lines 270-299):

{/* Admin notes */}
<div className="shrink-0 p-6 border-t border-[var(--color-border)] space-y-4">
  <div>
    <label className="text-xs text-[var(--color-muted-foreground)] uppercase tracking-wide block mb-2">
      {(m.admin_inquiry_notes as () => string)?.() ?? "Admin Notes"}
    </label>
    <Textarea ... />
    <div className="flex justify-end mt-2">
      <Button ...>
        {isSavingNotes ? "Saving..." : "Save Note"}
      </Button>
    </div>
  </div>
</div>

Replace the entire admin notes section with:

{
  /* Follow-up notes */
}
<div className="shrink-0 p-6 border-t border-[var(--color-border)] space-y-4">
  <label className="text-xs text-[var(--color-muted-foreground)] uppercase tracking-wide block">
    {(m.admin_inquiry_notes as () => string)?.() ?? "Notes"}
  </label>
  {inquiry && <FollowUpTimeline inquiryId={inquiry._id} />}
</div>;
  • Step 3: Remove unused imports and state

Remove from imports (since notes, isSavingNotes, updateNotes, handleSaveNotes are no longer needed):

  • useState (still needed for other things — check before removing)
  • updateNotes mutation import
  • Textarea import (if only used for notes)

Check the file — the notes state and updateNotes / handleSaveNotes should now be removable. The FieldDisplay component and handleStatusChange remain.

  • Step 4: Add i18n key for no notes

In apps/frontend/messages/en.json, add:

"admin_inquiry_noNotes": "No notes yet"
  • Step 5: Commit
git add apps/frontend/components/admin/inquiry-detail-panel.tsx apps/frontend/messages/en.json
git commit -m "feat(inquiry): replace textarea with FollowUpTimeline in detail panel"

Task 5: Build and Smoke Test

  • Step 1: Run type check
cd apps/frontend && npx tsc --noEmit 2>&1 | head -50

Expected: No TypeScript errors

  • Step 2: Run lint
cd apps/frontend && npx eslint components/admin/follow-up-timeline.tsx components/admin/inquiry-detail-panel.tsx --ext .tsx 2>&1 | head -20

Expected: No errors

  • Step 3: Manual test

Navigate to /dashboard/inquiries/contact, click a card, verify:

  • Existing notes display in timeline order (oldest first)
  • New note form submits and appears in timeline
  • Author name and timestamp shown per note
  • Vertical timeline line connects the dots

Self-Review Checklist

Spec coverage:

  • Staff can add text notes per inquiry
  • Notes persist in database (inquiryFollowUps table)
  • Full history visible to all staff (timeline)
  • Author + timestamp per note
  • Works across all 6 inquiry form types

No regressions:

  • InquiryDetailPanel still renders form data + status controls
  • onStatusChange still works for dragging cards between columns
  • All 6 inquiry pages (/contact, /private-events, etc.) still function

Code quality:

  • No any types introduced
  • Proper TypeScript throughout
  • Convex mutation properly typed with Id<"inquirySessions">
  • Input length limited to 2000 chars (matches DB constraint)

Outstanding (out of scope):

  • adminNotes field still exists on inquirySessions table but is now unused — can be removed in a separate cleanup task
  • No edit/delete for existing notes (YAGNI for now)