Add admin settings page with seed data and limitations toggle

- Add settings handler with endpoints for:
  - GET/PUT /api/admin/settings (enable_limitations toggle)
  - POST /api/admin/settings/seed-lookups (run 001_lookups.sql)
  - POST /api/admin/settings/seed-test-data (run 002_test_data.sql)

- Add settings page to admin panel with:
  - Toggle switch to enable/disable subscription limitations
  - Button to seed lookup data (categories, priorities, etc.)
  - Button to seed test data (with warning for non-production use)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-28 08:47:49 -06:00
parent 218e79220c
commit f02c1e6a64
4 changed files with 396 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Database, TestTube, Shield } from 'lucide-react';
import { settingsApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
export default function SettingsPage() {
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: settingsApi.get,
});
const updateMutation = useMutation({
mutationFn: settingsApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
toast.success('Settings updated');
},
onError: () => {
toast.error('Failed to update settings');
},
});
const seedLookupsMutation = useMutation({
mutationFn: settingsApi.seedLookups,
onSuccess: (data) => {
toast.success(data.message);
},
onError: () => {
toast.error('Failed to seed lookup data');
},
});
const seedTestDataMutation = useMutation({
mutationFn: settingsApi.seedTestData,
onSuccess: (data) => {
toast.success(data.message);
},
onError: () => {
toast.error('Failed to seed test data');
},
});
const handleLimitationsToggle = () => {
if (settings) {
updateMutation.mutate({
enable_limitations: !settings.enable_limitations,
});
}
};
if (isLoading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-muted-foreground">
Manage system settings and seed data
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Subscription Limitations */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Subscription Limitations
</CardTitle>
<CardDescription>
Control whether tier-based limitations are enforced for users
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="enable-limitations">Enable Limitations</Label>
<p className="text-sm text-muted-foreground">
When enabled, free tier users will have restricted access to features
</p>
</div>
<Switch
id="enable-limitations"
checked={settings?.enable_limitations ?? false}
onCheckedChange={handleLimitationsToggle}
disabled={updateMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Seed Lookup Data */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Seed Lookup Data
</CardTitle>
<CardDescription>
Populate or refresh static lookup tables (categories, priorities, statuses, etc.)
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={seedLookupsMutation.isPending}
>
{seedLookupsMutation.isPending ? 'Seeding...' : 'Seed Lookup Data'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Seed Lookup Data?</AlertDialogTitle>
<AlertDialogDescription>
This will insert or update all lookup tables including:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Residence types</li>
<li>Task categories, priorities, statuses, frequencies</li>
<li>Contractor specialties</li>
<li>Subscription tiers and feature benefits</li>
</ul>
Existing data will be preserved or updated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => seedLookupsMutation.mutate()}>
Seed Data
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
{/* Seed Test Data */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TestTube className="h-5 w-5" />
Seed Test Data
</CardTitle>
<CardDescription>
Populate the database with sample users, residences, and tasks for testing
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={seedTestDataMutation.isPending}
>
{seedTestDataMutation.isPending ? 'Seeding...' : 'Seed Test Data'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Seed Test Data?</AlertDialogTitle>
<AlertDialogDescription>
This will create sample data for testing including:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Test users</li>
<li>Sample residences</li>
<li>Example tasks and completions</li>
</ul>
<strong className="text-destructive block mt-2">
Warning: This should only be used in development/testing environments.
</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => seedTestDataMutation.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Seed Test Data
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -632,4 +632,36 @@ export const notificationPrefsApi = {
},
};
// Settings types
export interface SystemSettings {
enable_limitations: boolean;
}
export interface UpdateSettingsRequest {
enable_limitations?: boolean;
}
// Settings API
export const settingsApi = {
get: async (): Promise<SystemSettings> => {
const response = await api.get<SystemSettings>('/settings');
return response.data;
},
update: async (data: UpdateSettingsRequest): Promise<SystemSettings> => {
const response = await api.put<SystemSettings>('/settings', data);
return response.data;
},
seedLookups: async (): Promise<{ message: string }> => {
const response = await api.post<{ message: string }>('/settings/seed-lookups');
return response.data;
},
seedTestData: async (): Promise<{ message: string }> => {
const response = await api.post<{ message: string }>('/settings/seed-test-data');
return response.data;
},
};
export default api;

View File

@@ -0,0 +1,128 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminSettingsHandler handles system settings management
type AdminSettingsHandler struct {
db *gorm.DB
}
// NewAdminSettingsHandler creates a new handler
func NewAdminSettingsHandler(db *gorm.DB) *AdminSettingsHandler {
return &AdminSettingsHandler{db: db}
}
// SettingsResponse represents the settings response
type SettingsResponse struct {
EnableLimitations bool `json:"enable_limitations"`
}
// GetSettings handles GET /api/admin/settings
func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create default settings
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
h.db.Create(&settings)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
}
c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
})
}
// UpdateSettingsRequest represents the update request
type UpdateSettingsRequest struct {
EnableLimitations *bool `json:"enable_limitations"`
}
// UpdateSettings handles PUT /api/admin/settings
func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
var req UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
settings = models.SubscriptionSettings{ID: 1}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
}
if req.EnableLimitations != nil {
settings.EnableLimitations = *req.EnableLimitations
}
if err := h.db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
})
}
// SeedLookups handles POST /api/admin/settings/seed-lookups
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
if err := h.runSeedFile("001_lookups.sql"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed lookups: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Lookup data seeded successfully"})
}
// SeedTestData handles POST /api/admin/settings/seed-test-data
func (h *AdminSettingsHandler) SeedTestData(c *gin.Context) {
if err := h.runSeedFile("002_test_data.sql"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed test data: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test data seeded successfully"})
}
// runSeedFile executes a seed SQL file
func (h *AdminSettingsHandler) runSeedFile(filename string) error {
// Check multiple possible locations
possiblePaths := []string{
filepath.Join("seeds", filename),
filepath.Join("./seeds", filename),
filepath.Join("/app/seeds", filename),
}
var sqlContent []byte
var err error
for _, path := range possiblePaths {
sqlContent, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return err
}
return h.db.Exec(string(sqlContent)).Error
}

View File

@@ -249,6 +249,16 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
notifPrefs.DELETE("/:id", notifPrefsHandler.Delete)
notifPrefs.GET("/user/:user_id", notifPrefsHandler.GetByUser)
}
// System settings management
settingsHandler := handlers.NewAdminSettingsHandler(db)
settings := protected.Group("/settings")
{
settings.GET("", settingsHandler.GetSettings)
settings.PUT("", settingsHandler.UpdateSettings)
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
}
}
}