Date: 2026-01-10 Status: Implementation Guide
This guide shows how to migrate from dedicated API endpoints and React components to the simplified query interface for common admin operations.
Phase 1: Parallel Operation (Weeks 1-2)
- Query interface available at
/admin/query - Existing endpoints remain functional
- User training and adoption
Phase 2: Gradual Migration (Weeks 3-4)
- Move workflows to query interface
- Mark old endpoints as deprecated
- Monitor usage analytics
Phase 3: Cleanup (Week 5+)
- Remove unused endpoints
- Archive old components
- Update documentation
Before (Over-Engineered):
// API: /api/admin/students?search=john&status=active&level=B1
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search');
const status = searchParams.get('status');
const level = searchParams.get('level');
let query = db.select().from(users)
.where(and(eq(users.role, 'student'), isNull(users.deleted_at)));
if (search) {
query = query.where(or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`)
));
}
if (status) {
query = query.where(eq(users.status, status));
}
if (level) {
query = query.where(eq(users.current_level, level));
}
const students = await query.limit(100).orderBy(users.name);
return NextResponse.json({ students });
}
// Client: Complex component with filters
<StudentFilters
onStatusChange={setStatus}
onLevelChange={setLevel}
onSearchChange={setSearch}
/>
<StudentList students={filteredStudents} />After (Simplified):
Natural Language: "Show me active students in B1 level named John"
↓ AI Translation
SQL: SELECT * FROM users WHERE role='student' AND status='active'
AND current_level='B1' AND name LIKE '%John%' AND deleted_at IS NULL
↓ Execute → Display
Migration Steps:
- Add link to Query Interface in Student Registry page
- Show tutorial for common queries
- After 2 weeks, deprecate
/api/admin/studentsendpoint - Remove StudentFilters component (keep StudentList for detail view)
Lines of Code Removed: ~300 (API + Component)
Before (Over-Engineered):
// API: /api/admin/reports/visa-expiring
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const days = parseInt(searchParams.get('days') || '30');
const students = await db
.select()
.from(users)
.where(
and(
eq(users.role, 'student'),
sql`${users.visa_expiry} BETWEEN NOW() AND NOW() + INTERVAL '${days} days'`,
isNull(users.deleted_at)
)
);
// Generate CSV on server
const csv = generateCSV(students);
// Upload to storage
const url = await uploadToStorage(csv);
return NextResponse.json({ url, expires: '24h' });
}
// Client: Dedicated component
<VisaExpiryReport onGenerate={handleGenerate} />After (Simplified):
Natural Language: "Show students whose visas expire in the next 30 days"
↓ Execute
↓ Click "Export CSV" (client-side download, instant)
Migration Steps:
- Remove
/api/admin/reports/visa-expiringendpoint - Remove VisaExpiryReport component
- Add query template to quick actions
Lines of Code Removed: ~200 (API + Component + CSV generation)
Before (Over-Engineered):
// Server Action: /app/admin/enrollments/_actions/updateStatus.ts
export async function updateEnrollmentStatus(
enrollmentId: string,
newStatus: string
) {
'use server';
// Validation
const schema = z.object({
enrollmentId: z.string().uuid(),
newStatus: z.enum(['active', 'completed', 'withdrawn']),
});
const result = schema.safeParse({ enrollmentId, newStatus });
if (!result.success) {
return { error: result.error.message };
}
// Transaction
await db.transaction(async (tx) => {
await tx
.update(enrollments)
.set({ status: newStatus, updated_at: new Date() })
.where(eq(enrollments.id, enrollmentId));
// Audit log
await tx.insert(auditLog).values({
entity: 'enrollment',
entity_id: enrollmentId,
action: 'update',
changes: { status: newStatus },
});
});
revalidatePath('/admin/enrollments');
return { success: true };
}
// Client: Form with validation
<UpdateStatusForm enrollment={enrollment} onUpdate={updateEnrollmentStatus} />After (Simplified with Confirmation):
Natural Language: "Update enrollment X to completed status"
↓ AI Translation
SQL: UPDATE enrollments SET status='completed', updated_at=NOW()
WHERE id='X'
↓ Preview: "This will affect 1 row. Proceed?"
↓ Confirm → Execute
↓ Audit log automatically created by trigger
Migration Steps:
- Add confirmation workflow for UPDATE queries
- Show affected rows before execution
- Keep audit log trigger in database
- Remove server action + form component
Lines of Code Removed: ~150 (Server Action + Form)
Before (Over-Engineered):
// API: /api/admin/classes/[id]/roster/export
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const classId = params.id;
// Fetch data with joins
const roster = await db
.select({
studentName: users.name,
studentEmail: users.email,
enrollmentStatus: enrollments.status,
startDate: enrollments.start_date,
})
.from(enrollments)
.innerJoin(users, eq(enrollments.student_id, users.id))
.where(
and(
eq(enrollments.class_id, classId),
eq(enrollments.status, 'active')
)
);
// Generate CSV on server
const csv = generateCSV(roster);
// Upload to storage
const { url } = await supabaseStorage.upload('exports', csv);
// Return signed URL
const signedUrl = await supabaseStorage.getSignedUrl(url, 86400);
return NextResponse.json({ url: signedUrl, expires: '24h' });
}After (Simplified):
Natural Language: "Show all students enrolled in class X"
↓ Execute
↓ Click "Export CSV" (instant client-side download)
Migration Steps:
- Remove export endpoint
- Remove storage upload logic
- Use client-side CSV generation
Lines of Code Removed: ~100 (API + Storage logic)
Before:
- Complex client-side filtering
- Saved views with URL params
- Multiple filter dropdowns
- Search input with debouncing
After:
- Link to
/admin/querywith suggested queries - Quick action buttons that pre-fill natural language input
- Keep detail drawer for individual student view
Migration:
// Add to StudentRegistry.tsx
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
<p className="text-sm text-blue-800 mb-2">
💡 Try the new Query Interface for faster, more flexible searches!
</p>
<Link href="/admin/query?q=Show me active students">
<Button variant="outline" size="sm">
Open Query Interface →
</Button>
</Link>
</div>Since we're removing server-side audit log logic, add database triggers:
-- Trigger for enrollment updates
CREATE OR REPLACE FUNCTION log_enrollment_changes()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (entity, entity_id, action, changes, created_at)
VALUES (
'enrollment',
NEW.id,
'update',
jsonb_build_object(
'old', row_to_json(OLD),
'new', row_to_json(NEW)
),
NOW()
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enrollment_audit_trigger
AFTER UPDATE ON enrollments
FOR EACH ROW
EXECUTE FUNCTION log_enrollment_changes();For each migrated operation:
- Functionality: Query produces same results as old endpoint
- Performance: Query completes in < 5 seconds
- Security: RLS policies still enforced
- UX: Non-technical users can perform operation
- Error Handling: Clear error messages for invalid queries
// Test: Student search
describe('Student Search Migration', () => {
it('should return same results as old endpoint', async () => {
// Old endpoint
const oldResults = await fetch('/api/admin/students?search=john');
// New query interface
const newResults = await fetch('/api/admin/query/translate', {
method: 'POST',
body: JSON.stringify({ query: 'Show students named John' }),
});
expect(newResults.data).toEqual(oldResults.students);
});
});For Non-Technical Users:
-
Natural Language Mode (Recommended):
- Type what you want to see in plain English
- Review the generated SQL
- Click "Execute" to run the query
- Export results if needed
-
Query Builder Mode (More Control):
- Select a table
- Choose columns to display
- Add filters step-by-step
- Run and export results
| Task | Natural Language Query |
|---|---|
| View all students | "Show me all students" |
| Filter by status | "Show active students" |
| Filter by level | "Show students in B1 level" |
| Visa expiring | "Show students whose visas expire in the next 30 days" |
| Recent enrollments | "Show enrollments from this month" |
| Class roster | "Show all students in Pre-Intermediate class" |
| Student by name | "Find student named John Smith" |
If issues arise during migration:
- Keep old endpoints active in parallel
- Disable query interface routing
- Revert to previous UI
- Review error logs
- Identify root cause
- Fix issues in staging
- Deploy fixes
- Re-enable query interface
- Monitor metrics
| Metric | Target | Measurement |
|---|---|---|
| Adoption Rate | 80% of admins use query interface | Analytics |
| Time Savings | 60% reduction in common operations | User surveys |
| Error Rate | < 5% query execution errors | Server logs |
| User Satisfaction | NPS > 50 | Quarterly survey |
| Endpoint Deprecation | 20 endpoints removed | Code audit |
- ✅ Query interface deployed
- ✅ Documentation created
- ⏳ User training sessions
- ⏳ Adoption monitoring
- ⏳ Move workflows to query interface
- ⏳ Deprecate redundant endpoints
- ⏳ Update internal documentation
- ⏳ Remove deprecated endpoints
- ⏳ Archive old components
- ⏳ Performance review
A: Old endpoints remain functional during transition. Users should update bookmarks to /admin/query.
A: Yes, during Phase 1-2. Eventually it will redirect to query interface with suggested queries.
A: Use the Query Builder mode for more control, or contact support for assistance.
A: Yes, with confirmation workflow. Shows affected rows before execution.
A: Feature coming in Phase 2. Currently use browser bookmarks or copy queries to notes.
For migration assistance:
- Review this guide
- Check inline help in
/admin/query - Contact: eoin@mycastle.app
Version: 1.0 Last Updated: 2026-01-10 Status: Active Migration