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
inquiryFollowUpstable stores one row per note (inquiryId, authorId, authorName, content, createdAt) adminNotesfield oninquirySessionsis removed in a later migration — for now it's unusedFollowUpTimelinecomponent renders the note list + input formInquiryDetailPanelswaps 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 keysTask 1: Add inquiryFollowUps Table to Schema
Files:
-
Modify:
packages/backend/convex/schema.ts -
Step 1: Find the closing of
inquirySessionstable (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
listFollowUpsquery andcreateFollowUpmutation
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 localnotesstate - 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)updateNotesmutation importTextareaimport (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 -50Expected: 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 -20Expected: 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 (
inquiryFollowUpstable) - Full history visible to all staff (timeline)
- Author + timestamp per note
- Works across all 6 inquiry form types
No regressions:
-
InquiryDetailPanelstill renders form data + status controls -
onStatusChangestill works for dragging cards between columns - All 6 inquiry pages (
/contact,/private-events, etc.) still function
Code quality:
- No
anytypes introduced - Proper TypeScript throughout
- Convex mutation properly typed with
Id<"inquirySessions"> - Input length limited to 2000 chars (matches DB constraint)
Outstanding (out of scope):
adminNotesfield still exists oninquirySessionstable but is now unused — can be removed in a separate cleanup task- No edit/delete for existing notes (YAGNI for now)