Add task templates API and admin management
- Add TaskTemplate model with category and frequency support - Add task template repository with CRUD and search operations - Add task template service layer - Add public API endpoints for templates (no auth required): - GET /api/tasks/templates/ - list all templates - GET /api/tasks/templates/grouped/ - templates grouped by category - GET /api/tasks/templates/search/?q= - search templates - GET /api/tasks/templates/by-category/:id/ - templates by category - GET /api/tasks/templates/:id/ - single template - Add admin panel for task template management (CRUD) - Add admin API endpoints for templates - Add seed file with predefined task templates - Add i18n translations for template errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,7 @@ export default function SettingsPage() {
|
||||
Seed Lookup Data
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Populate or refresh static lookup tables (categories, priorities, statuses, etc.)
|
||||
Populate or refresh static lookup tables and task templates
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -97,6 +97,7 @@ export default function SettingsPage() {
|
||||
<li>Task categories, priorities, statuses, frequencies</li>
|
||||
<li>Contractor specialties</li>
|
||||
<li>Subscription tiers and feature benefits</li>
|
||||
<li><strong>Task templates (60+ predefined tasks)</strong></li>
|
||||
</ul>
|
||||
Existing data will be preserved or updated.
|
||||
</AlertDialogDescription>
|
||||
|
||||
445
admin/src/app/(dashboard)/task-templates/page.tsx
Normal file
445
admin/src/app/(dashboard)/task-templates/page.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LayoutTemplate, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight, Power, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { taskTemplatesApi, lookupsApi, type TaskTemplate } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function TaskTemplatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [params, setParams] = useState<{
|
||||
page: number;
|
||||
per_page: number;
|
||||
search: string;
|
||||
category_id?: number;
|
||||
frequency_id?: number;
|
||||
is_active?: boolean;
|
||||
}>({ page: 1, per_page: 20, search: '' });
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState<string>('');
|
||||
const [filterFrequency, setFilterFrequency] = useState<string>('');
|
||||
const [filterActive, setFilterActive] = useState<string>('');
|
||||
const [editingTemplate, setEditingTemplate] = useState<TaskTemplate | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category_id: undefined as number | undefined,
|
||||
frequency_id: undefined as number | undefined,
|
||||
icon_ios: '',
|
||||
icon_android: '',
|
||||
tags: '',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['task-templates', params],
|
||||
queryFn: () => taskTemplatesApi.list(params),
|
||||
});
|
||||
|
||||
// Fetch categories and frequencies for dropdowns
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['lookup-categories'],
|
||||
queryFn: () => lookupsApi.categories.list(),
|
||||
});
|
||||
|
||||
const { data: frequencies } = useQuery({
|
||||
queryKey: ['lookup-frequencies'],
|
||||
queryFn: () => lookupsApi.frequencies.list(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: taskTemplatesApi.create,
|
||||
onSuccess: () => {
|
||||
toast.success('Task template created');
|
||||
queryClient.invalidateQueries({ queryKey: ['task-templates'] });
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: () => toast.error('Failed to create task template'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => taskTemplatesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Task template updated');
|
||||
queryClient.invalidateQueries({ queryKey: ['task-templates'] });
|
||||
setEditingTemplate(null);
|
||||
resetForm();
|
||||
},
|
||||
onError: () => toast.error('Failed to update task template'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: taskTemplatesApi.delete,
|
||||
onSuccess: () => {
|
||||
toast.success('Task template deleted');
|
||||
queryClient.invalidateQueries({ queryKey: ['task-templates'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to delete task template'),
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: taskTemplatesApi.toggleActive,
|
||||
onSuccess: () => {
|
||||
toast.success('Template status updated');
|
||||
queryClient.invalidateQueries({ queryKey: ['task-templates'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to update template status'),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
category_id: undefined,
|
||||
frequency_id: undefined,
|
||||
icon_ios: '',
|
||||
icon_android: '',
|
||||
tags: '',
|
||||
display_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (template: TaskTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setFormData({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
category_id: template.category_id ?? undefined,
|
||||
frequency_id: template.frequency_id ?? undefined,
|
||||
icon_ios: template.icon_ios,
|
||||
icon_android: template.icon_android,
|
||||
tags: template.tags,
|
||||
display_order: template.display_order,
|
||||
is_active: template.is_active,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editingTemplate) {
|
||||
updateMutation.mutate({ id: editingTemplate.id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / params.per_page) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Task Templates</h1>
|
||||
<p className="text-muted-foreground">Manage predefined task templates for autocomplete</p>
|
||||
</div>
|
||||
<Button onClick={() => { setIsCreating(true); resetForm(); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setParams({
|
||||
...params,
|
||||
search,
|
||||
page: 1,
|
||||
category_id: filterCategory && filterCategory !== 'all' ? parseInt(filterCategory) : undefined,
|
||||
frequency_id: filterFrequency && filterFrequency !== 'all' ? parseInt(filterFrequency) : undefined,
|
||||
is_active: filterActive === '' || filterActive === 'all' ? undefined : filterActive === 'true',
|
||||
});
|
||||
}} className="flex flex-wrap gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input placeholder="Search by title or tags..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
|
||||
</div>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories?.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id.toString()}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterFrequency} onValueChange={setFilterFrequency}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All Frequencies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Frequencies</SelectItem>
|
||||
{frequencies?.map((freq) => (
|
||||
<SelectItem key={freq.id} value={freq.id.toString()}>{freq.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterActive} onValueChange={setFilterActive}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="true">Active</SelectItem>
|
||||
<SelectItem value="false">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" variant="secondary">Apply</Button>
|
||||
{(search || (filterCategory && filterCategory !== 'all') || (filterFrequency && filterFrequency !== 'all') || (filterActive && filterActive !== 'all')) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setFilterCategory('');
|
||||
setFilterFrequency('');
|
||||
setFilterActive('');
|
||||
setParams({ page: 1, per_page: 20, search: '' });
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Order</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Frequency</TableHead>
|
||||
<TableHead>iOS Icon</TableHead>
|
||||
<TableHead>Active</TableHead>
|
||||
<TableHead className="w-32">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8">Loading...</TableCell></TableRow>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8">No task templates found. Click "Seed Lookup Data" in Settings to add templates.</TableCell></TableRow>
|
||||
) : (
|
||||
data?.data?.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className="text-muted-foreground">{template.display_order}</TableCell>
|
||||
<TableCell className="font-medium max-w-xs">
|
||||
<div className="truncate">{template.title}</div>
|
||||
{template.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">{template.description}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{template.category?.name || 'Uncategorized'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{template.frequency?.name || '-'}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{template.icon_ios || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={template.is_active ? 'default' : 'secondary'}>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleActiveMutation.mutate(template.id)}
|
||||
title={template.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
<Power className={`h-4 w-4 ${template.is_active ? 'text-green-600' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(template)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This will permanently delete "{template.title}".</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteMutation.mutate(template.id)} className="bg-destructive">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((params.page - 1) * params.per_page) + 1} - {Math.min(params.page * params.per_page, data.total)} of {data.total}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={params.page <= 1} onClick={() => setParams({ ...params, page: params.page - 1 })}>
|
||||
<ChevronLeft className="h-4 w-4" /> Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={params.page >= totalPages} onClick={() => setParams({ ...params, page: params.page + 1 })}>
|
||||
Next <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isCreating || !!editingTemplate} onOpenChange={(open) => { if (!open) { setIsCreating(false); setEditingTemplate(null); } }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTemplate ? 'Edit Task Template' : 'Create Task Template'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Label>Title *</Label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="e.g., Clean HVAC Filter"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Detailed instructions for this task..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={formData.category_id?.toString() ?? ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, category_id: value ? parseInt(value) : undefined })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories?.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id.toString()}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Frequency</Label>
|
||||
<Select
|
||||
value={formData.frequency_id?.toString() ?? ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, frequency_id: value ? parseInt(value) : undefined })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{frequencies?.map((freq) => (
|
||||
<SelectItem key={freq.id} value={freq.id.toString()}>{freq.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>iOS Icon (SF Symbol)</Label>
|
||||
<Input
|
||||
value={formData.icon_ios}
|
||||
onChange={(e) => setFormData({ ...formData, icon_ios: e.target.value })}
|
||||
placeholder="e.g., wrench.fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Android Icon (Material)</Label>
|
||||
<Input
|
||||
value={formData.icon_android}
|
||||
onChange={(e) => setFormData({ ...formData, icon_android: e.target.value })}
|
||||
placeholder="e.g., Build"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tags (comma-separated)</Label>
|
||||
<Input
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="e.g., hvac, filter, maintenance"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Display Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.display_order}
|
||||
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<Switch
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setIsCreating(false); setEditingTemplate(null); }}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!formData.title}>
|
||||
{editingTemplate ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Share2,
|
||||
KeyRound,
|
||||
Smartphone,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
@@ -65,6 +66,7 @@ const limitationsItems = [
|
||||
|
||||
const settingsItems = [
|
||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
||||
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
|
||||
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
||||
{ title: 'Settings', url: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -1138,4 +1138,105 @@ export const promotionsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Task Template Types
|
||||
export interface TaskTemplateCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface TaskTemplateFrequency {
|
||||
id: number;
|
||||
name: string;
|
||||
days: number;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category_id: number | null;
|
||||
category?: TaskTemplateCategory;
|
||||
frequency_id: number | null;
|
||||
frequency?: TaskTemplateFrequency;
|
||||
icon_ios: string;
|
||||
icon_android: string;
|
||||
tags: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskTemplateListParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: 'asc' | 'desc';
|
||||
category_id?: number;
|
||||
frequency_id?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTaskTemplateRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
category_id?: number;
|
||||
frequency_id?: number;
|
||||
icon_ios?: string;
|
||||
icon_android?: string;
|
||||
tags?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTaskTemplateRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
category_id?: number | null;
|
||||
frequency_id?: number | null;
|
||||
icon_ios?: string;
|
||||
icon_android?: string;
|
||||
tags?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// Task Templates API
|
||||
export const taskTemplatesApi = {
|
||||
list: async (params?: TaskTemplateListParams): Promise<PaginatedResponse<TaskTemplate>> => {
|
||||
const response = await api.get<PaginatedResponse<TaskTemplate>>('/task-templates', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<TaskTemplate> => {
|
||||
const response = await api.get<TaskTemplate>(`/task-templates/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateTaskTemplateRequest): Promise<TaskTemplate> => {
|
||||
const response = await api.post<TaskTemplate>('/task-templates', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, data: UpdateTaskTemplateRequest): Promise<TaskTemplate> => {
|
||||
const response = await api.put<TaskTemplate>(`/task-templates/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/task-templates/${id}`);
|
||||
},
|
||||
|
||||
toggleActive: async (id: number): Promise<TaskTemplate> => {
|
||||
const response = await api.post<TaskTemplate>(`/task-templates/${id}/toggle-active`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
151
docs/Dokku_notes
Normal file
151
docs/Dokku_notes
Normal file
@@ -0,0 +1,151 @@
|
||||
wget -NP . https://dokku.com/install/v0.36.10/bootstrap.sh
|
||||
sudo DOKKU_TAG=v0.36.10 bash bootstrap.sh
|
||||
|
||||
# make sure authorized_keys has value first
|
||||
cat ~/.ssh/authorized_keys | dokku ssh-keys:add admin
|
||||
dokku domains:set-global 45.56.68.144
|
||||
|
||||
sudo dokku apps:create mycrib
|
||||
sudo dokku plugin:install https://github.com/dokku/dokku-postgres.git
|
||||
sudo dokku postgres:create mycrib
|
||||
sudo dokku plugin:install https://github.com/dokku/dokku-redis.git redis
|
||||
sudo dokku redis:create mycrib
|
||||
|
||||
sudo dokku postgres:link mycrib mycrib
|
||||
sudo dokku redis:link mycrib mycrib
|
||||
|
||||
sudo dokku config:set --no-restart mycrib SECRET_KEY=wz%C*@^*wKZK9bV3CBMt7cj3wk2y^0vdu@2*pz7yco+p=7@sa%
|
||||
|
||||
dokku storage:mount mycrib /var/lib/dokku/data/storage/mycrib:/code/media/
|
||||
|
||||
# add remote dokku server as a remote repo in git
|
||||
# this has to be done for git to be able to use the right key
|
||||
Host mycribDev
|
||||
HostName 104.200.20.223
|
||||
User root
|
||||
PreferredAuthentications publickey
|
||||
IdentityFile /Users/treyt/.ssh/linode_88oakapps
|
||||
IdentitiesOnly yes
|
||||
AddKeysToAgent yes
|
||||
|
||||
|
||||
git remote add dokku dokku@mycribDev:mycrib
|
||||
|
||||
# to push
|
||||
git push dokku@mycribDev:mycrib develop
|
||||
|
||||
sudo dokku domains:add mycrib mycrib.treytartt.com
|
||||
sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git
|
||||
sudo dokku config:set --no-restart --global DOKKU_LETSENCRYPT_EMAIL=mycrib@treymail.com
|
||||
sudo dokku letsencrypt:set mycrib email mycrib@treymail.com
|
||||
sudo dokku letsencrypt:enable mycrib
|
||||
sudo dokku letsencrypt:cron-job --add
|
||||
sudo dokku plugin:install https://github.com/dokku/dokku-redirect.git
|
||||
|
||||
# media
|
||||
dokku storage:ensure-directory mycrib
|
||||
dokku storage:mount mycrib /var/lib/dokku/data/storage/mycrib/media:/code/media
|
||||
|
||||
|
||||
# Make sure your settings.py has:
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = '/code/media/' # This matches the container path you mounted
|
||||
|
||||
dokku enter mycrib
|
||||
chown -R dokku:dokku media # gives invalid user?
|
||||
chmod -R 755 media
|
||||
|
||||
# make sure storage is listed
|
||||
dokku storage:list mycrib
|
||||
# list
|
||||
/var/lib/dokku/data/storage/mycrib:/code/media/
|
||||
|
||||
# add location conf
|
||||
nano /home/dokku/mycrib/nginx.conf.d/media.conf
|
||||
|
||||
# paste in
|
||||
location /media/ {
|
||||
alias /var/lib/dokku/data/storage/mycrib/;
|
||||
}
|
||||
|
||||
# Unmount storage (if needed)
|
||||
dokku storage:unmount mycrib /var/lib/dokku/data/storage/mycrib:/app/media
|
||||
|
||||
# helpful commands
|
||||
# restart app
|
||||
dokku ps:restart mycrib
|
||||
|
||||
# enter docker container
|
||||
dokku enter mycrib web
|
||||
|
||||
#check if debug
|
||||
dokku run werkout python manage.py shell
|
||||
from django.conf import settings
|
||||
print(settings.DEBUG)
|
||||
exit()
|
||||
|
||||
# clear out space
|
||||
docker builder prune -a
|
||||
docker system prune -a
|
||||
|
||||
# create super user
|
||||
dokku run mycrib python manage.py createsuperuser
|
||||
|
||||
# postgres info
|
||||
dokku postgres:info your-database-db
|
||||
postgres://postgres:bf8f1cb443c35cd1ae0a58617ef348cd@dokku-postgres-your-database-db:5432/your_database_db
|
||||
[database type]://[username]:[password]@[host]:[port]/[database name]
|
||||
|
||||
# show env
|
||||
dokku config:show mycrib
|
||||
|
||||
# show how dokku is doing
|
||||
dokku ps:report
|
||||
|
||||
# check workers are working
|
||||
dokku ps:scale mycrib
|
||||
|
||||
# if not
|
||||
dokku ps:scale mycrib web=1 worker=3 beat=1
|
||||
|
||||
|
||||
# added this b/c claude told me to but doesnt look to be needed
|
||||
dokku config:set mycrib \
|
||||
SECURE_PROXY_SSL_HEADER="HTTP_X_FORWARDED_PROTO,https" \
|
||||
USE_X_FORWARDED_HOST="True"
|
||||
|
||||
# dev smtp stuff
|
||||
dokku config:set --no-restart mycrib \
|
||||
EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" \
|
||||
EMAIL_HOST="smtp.fastmail.com" \
|
||||
EMAIL_PORT="587" \
|
||||
EMAIL_USE_TLS="True" \
|
||||
EMAIL_HOST_USER="treytartt@fastmail.com" \
|
||||
EMAIL_HOST_PASSWORD="2t9y4n4t497z5863" \
|
||||
DEFAULT_FROM_EMAIL="MyCrib <crib@treymail.com>"
|
||||
|
||||
# for prod
|
||||
dokku config:set mycrib \
|
||||
APNS_AUTH_KEY_PATH="/code/push_certs/AuthKey_5PP558G7W7.p8" \
|
||||
APNS_AUTH_KEY_ID="5PP558G7W7" \
|
||||
APNS_TEAM_ID="V3PF3M6B6U" \
|
||||
APNS_TOPIC="com.tt.mycrib.MyCribDev" \
|
||||
APNS_USE_SANDBOX="False"
|
||||
|
||||
dokku config:set casera-api \
|
||||
APNS_AUTH_KEY_PATH=/app/push_certs/AuthKey_9R5Q7ZX874.p8 \
|
||||
APNS_AUTH_KEY_ID=9R5Q7ZX874 \
|
||||
APNS_TEAM_ID=V3PF3M6B6U \
|
||||
APNS_TOPIC=com.tt.casera.CaseraDev \
|
||||
APNS_PRODUCTION=true
|
||||
|
||||
Production (mycribProdText):
|
||||
dokku config:set mycrib GUNICORN_WORKERS=6 GUNICORN_THREADS=2
|
||||
dokku ps:scale mycrib web=3
|
||||
|
||||
Development (mycribDev):
|
||||
dokku config:set mycrib GUNICORN_WORKERS=4 GUNICORN_THREADS=1
|
||||
dokku ps:scale mycrib web=1
|
||||
|
||||
# view worker logs
|
||||
dokku logs mycrib worker -t
|
||||
@@ -85,13 +85,21 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
// SeedLookups handles POST /api/admin/settings/seed-lookups
|
||||
// Seeds both lookup tables AND task templates
|
||||
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
|
||||
// First seed lookup tables
|
||||
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"})
|
||||
// Then seed task templates
|
||||
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Lookup data and task templates seeded successfully"})
|
||||
}
|
||||
|
||||
// SeedTestData handles POST /api/admin/settings/seed-test-data
|
||||
@@ -104,6 +112,16 @@ func (h *AdminSettingsHandler) SeedTestData(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test data seeded successfully"})
|
||||
}
|
||||
|
||||
// SeedTaskTemplates handles POST /api/admin/settings/seed-task-templates
|
||||
func (h *AdminSettingsHandler) SeedTaskTemplates(c *gin.Context) {
|
||||
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Task templates seeded successfully"})
|
||||
}
|
||||
|
||||
// runSeedFile executes a seed SQL file
|
||||
func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
||||
// Check multiple possible locations
|
||||
|
||||
321
internal/admin/handlers/task_template_handler.go
Normal file
321
internal/admin/handlers/task_template_handler.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// AdminTaskTemplateHandler handles admin task template management endpoints
|
||||
type AdminTaskTemplateHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminTaskTemplateHandler creates a new admin task template handler
|
||||
func NewAdminTaskTemplateHandler(db *gorm.DB) *AdminTaskTemplateHandler {
|
||||
return &AdminTaskTemplateHandler{db: db}
|
||||
}
|
||||
|
||||
// TaskTemplateResponse represents a task template in admin responses
|
||||
type TaskTemplateResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Category *TaskCategoryResponse `json:"category,omitempty"`
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||
IconIOS string `json:"icon_ios"`
|
||||
IconAndroid string `json:"icon_android"`
|
||||
Tags string `json:"tags"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// CreateUpdateTaskTemplateRequest represents the request body for creating/updating templates
|
||||
type CreateUpdateTaskTemplateRequest struct {
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
IconIOS string `json:"icon_ios" binding:"max=100"`
|
||||
IconAndroid string `json:"icon_android" binding:"max=100"`
|
||||
Tags string `json:"tags"`
|
||||
DisplayOrder *int `json:"display_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ListTemplates handles GET /admin/api/task-templates/
|
||||
func (h *AdminTaskTemplateHandler) ListTemplates(c *gin.Context) {
|
||||
var templates []models.TaskTemplate
|
||||
query := h.db.Preload("Category").Preload("Frequency").Order("display_order ASC, title ASC")
|
||||
|
||||
// Optional filter by active status
|
||||
if activeParam := c.Query("is_active"); activeParam != "" {
|
||||
isActive := activeParam == "true"
|
||||
query = query.Where("is_active = ?", isActive)
|
||||
}
|
||||
|
||||
// Optional filter by category
|
||||
if categoryID := c.Query("category_id"); categoryID != "" {
|
||||
query = query.Where("category_id = ?", categoryID)
|
||||
}
|
||||
|
||||
// Optional filter by frequency
|
||||
if frequencyID := c.Query("frequency_id"); frequencyID != "" {
|
||||
query = query.Where("frequency_id = ?", frequencyID)
|
||||
}
|
||||
|
||||
// Optional search
|
||||
if search := c.Query("search"); search != "" {
|
||||
searchTerm := "%" + strings.ToLower(search) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(tags) LIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
if err := query.Find(&templates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]TaskTemplateResponse, len(templates))
|
||||
for i, t := range templates {
|
||||
responses[i] = h.toResponse(&t)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
|
||||
}
|
||||
|
||||
// GetTemplate handles GET /admin/api/task-templates/:id/
|
||||
func (h *AdminTaskTemplateHandler) GetTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var template models.TaskTemplate
|
||||
if err := h.db.Preload("Category").Preload("Frequency").First(&template, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// CreateTemplate handles POST /admin/api/task-templates/
|
||||
func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
|
||||
var req CreateUpdateTaskTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
template := models.TaskTemplate{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
CategoryID: req.CategoryID,
|
||||
FrequencyID: req.FrequencyID,
|
||||
IconIOS: req.IconIOS,
|
||||
IconAndroid: req.IconAndroid,
|
||||
Tags: req.Tags,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
|
||||
if req.DisplayOrder != nil {
|
||||
template.DisplayOrder = *req.DisplayOrder
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
template.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.db.Create(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// UpdateTemplate handles PUT /admin/api/task-templates/:id/
|
||||
func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var template models.TaskTemplate
|
||||
if err := h.db.First(&template, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateUpdateTaskTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
template.Title = req.Title
|
||||
template.Description = req.Description
|
||||
template.CategoryID = req.CategoryID
|
||||
template.FrequencyID = req.FrequencyID
|
||||
template.IconIOS = req.IconIOS
|
||||
template.IconAndroid = req.IconAndroid
|
||||
template.Tags = req.Tags
|
||||
|
||||
if req.DisplayOrder != nil {
|
||||
template.DisplayOrder = *req.DisplayOrder
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
template.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.db.Save(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||
|
||||
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// DeleteTemplate handles DELETE /admin/api/task-templates/:id/
|
||||
func (h *AdminTaskTemplateHandler) DeleteTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&models.TaskTemplate{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"})
|
||||
}
|
||||
|
||||
// ToggleActive handles POST /admin/api/task-templates/:id/toggle-active/
|
||||
func (h *AdminTaskTemplateHandler) ToggleActive(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var template models.TaskTemplate
|
||||
if err := h.db.First(&template, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||
return
|
||||
}
|
||||
|
||||
template.IsActive = !template.IsActive
|
||||
if err := h.db.Save(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||
|
||||
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// BulkCreate handles POST /admin/api/task-templates/bulk/
|
||||
func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
Templates []CreateUpdateTaskTemplateRequest `json:"templates" binding:"required,dive"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
templates := make([]models.TaskTemplate, len(req.Templates))
|
||||
for i, t := range req.Templates {
|
||||
templates[i] = models.TaskTemplate{
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
CategoryID: t.CategoryID,
|
||||
FrequencyID: t.FrequencyID,
|
||||
IconIOS: t.IconIOS,
|
||||
IconAndroid: t.IconAndroid,
|
||||
Tags: t.Tags,
|
||||
IsActive: true,
|
||||
}
|
||||
if t.DisplayOrder != nil {
|
||||
templates[i].DisplayOrder = *t.DisplayOrder
|
||||
}
|
||||
if t.IsActive != nil {
|
||||
templates[i].IsActive = *t.IsActive
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.Create(&templates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create templates"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)})
|
||||
}
|
||||
|
||||
// Helper to convert model to response
|
||||
func (h *AdminTaskTemplateHandler) toResponse(t *models.TaskTemplate) TaskTemplateResponse {
|
||||
resp := TaskTemplateResponse{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
CategoryID: t.CategoryID,
|
||||
FrequencyID: t.FrequencyID,
|
||||
IconIOS: t.IconIOS,
|
||||
IconAndroid: t.IconAndroid,
|
||||
Tags: t.Tags,
|
||||
DisplayOrder: t.DisplayOrder,
|
||||
IsActive: t.IsActive,
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
resp.Category = &TaskCategoryResponse{
|
||||
ID: t.Category.ID,
|
||||
Name: t.Category.Name,
|
||||
Description: t.Category.Description,
|
||||
Icon: t.Category.Icon,
|
||||
Color: t.Category.Color,
|
||||
DisplayOrder: t.Category.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
if t.Frequency != nil {
|
||||
resp.Frequency = &TaskFrequencyResponse{
|
||||
ID: t.Frequency.ID,
|
||||
Name: t.Frequency.Name,
|
||||
Days: t.Frequency.Days,
|
||||
DisplayOrder: t.Frequency.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -297,6 +297,19 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
specialties.DELETE("/:id", lookupHandler.DeleteSpecialty)
|
||||
}
|
||||
|
||||
// Task Templates management
|
||||
taskTemplateHandler := handlers.NewAdminTaskTemplateHandler(db)
|
||||
taskTemplates := protected.Group("/task-templates")
|
||||
{
|
||||
taskTemplates.GET("", taskTemplateHandler.ListTemplates)
|
||||
taskTemplates.POST("", taskTemplateHandler.CreateTemplate)
|
||||
taskTemplates.POST("/bulk", taskTemplateHandler.BulkCreate)
|
||||
taskTemplates.GET("/:id", taskTemplateHandler.GetTemplate)
|
||||
taskTemplates.PUT("/:id", taskTemplateHandler.UpdateTemplate)
|
||||
taskTemplates.DELETE("/:id", taskTemplateHandler.DeleteTemplate)
|
||||
taskTemplates.POST("/:id/toggle-active", taskTemplateHandler.ToggleActive)
|
||||
}
|
||||
|
||||
// Admin user management
|
||||
adminUserHandler := handlers.NewAdminUserManagementHandler(db)
|
||||
adminUsers := protected.Group("/admin-users")
|
||||
@@ -327,6 +340,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
settings.PUT("", settingsHandler.UpdateSettings)
|
||||
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
||||
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
||||
settings.POST("/seed-task-templates", settingsHandler.SeedTaskTemplates)
|
||||
settings.POST("/clear-all-data", settingsHandler.ClearAllData)
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ func Migrate() error {
|
||||
&models.TaskFrequency{},
|
||||
&models.TaskStatus{},
|
||||
&models.ContractorSpecialty{},
|
||||
&models.TaskTemplate{}, // Task templates reference category and frequency
|
||||
|
||||
// User and auth tables
|
||||
&models.User{},
|
||||
|
||||
134
internal/dto/responses/task_template.go
Normal file
134
internal/dto/responses/task_template.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// TaskTemplateResponse represents a task template in the API response
|
||||
type TaskTemplateResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Category *TaskCategoryResponse `json:"category,omitempty"`
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||
IconIOS string `json:"icon_ios"`
|
||||
IconAndroid string `json:"icon_android"`
|
||||
Tags []string `json:"tags"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskTemplateCategoryGroup represents templates grouped by category
|
||||
type TaskTemplateCategoryGroup struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Templates []TaskTemplateResponse `json:"templates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// TaskTemplatesGroupedResponse represents all templates grouped by category
|
||||
type TaskTemplatesGroupedResponse struct {
|
||||
Categories []TaskTemplateCategoryGroup `json:"categories"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
// NewTaskTemplateResponse creates a TaskTemplateResponse from a model
|
||||
func NewTaskTemplateResponse(t *models.TaskTemplate) TaskTemplateResponse {
|
||||
resp := TaskTemplateResponse{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
CategoryID: t.CategoryID,
|
||||
FrequencyID: t.FrequencyID,
|
||||
IconIOS: t.IconIOS,
|
||||
IconAndroid: t.IconAndroid,
|
||||
Tags: parseTags(t.Tags),
|
||||
DisplayOrder: t.DisplayOrder,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
resp.Category = NewTaskCategoryResponse(t.Category)
|
||||
}
|
||||
if t.Frequency != nil {
|
||||
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// NewTaskTemplateListResponse creates a list of task template responses
|
||||
func NewTaskTemplateListResponse(templates []models.TaskTemplate) []TaskTemplateResponse {
|
||||
results := make([]TaskTemplateResponse, len(templates))
|
||||
for i, t := range templates {
|
||||
results[i] = NewTaskTemplateResponse(&t)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// NewTaskTemplatesGroupedResponse creates a grouped response from templates
|
||||
func NewTaskTemplatesGroupedResponse(templates []models.TaskTemplate) TaskTemplatesGroupedResponse {
|
||||
// Group by category
|
||||
categoryMap := make(map[string]*TaskTemplateCategoryGroup)
|
||||
categoryOrder := []string{} // To maintain order
|
||||
|
||||
for _, t := range templates {
|
||||
categoryName := "Uncategorized"
|
||||
var categoryID *uint
|
||||
if t.Category != nil {
|
||||
categoryName = t.Category.Name
|
||||
categoryID = &t.Category.ID
|
||||
}
|
||||
|
||||
if _, exists := categoryMap[categoryName]; !exists {
|
||||
categoryMap[categoryName] = &TaskTemplateCategoryGroup{
|
||||
CategoryName: categoryName,
|
||||
CategoryID: categoryID,
|
||||
Templates: []TaskTemplateResponse{},
|
||||
}
|
||||
categoryOrder = append(categoryOrder, categoryName)
|
||||
}
|
||||
|
||||
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, NewTaskTemplateResponse(&t))
|
||||
}
|
||||
|
||||
// Build ordered result
|
||||
categories := make([]TaskTemplateCategoryGroup, len(categoryOrder))
|
||||
totalCount := 0
|
||||
for i, name := range categoryOrder {
|
||||
group := categoryMap[name]
|
||||
group.Count = len(group.Templates)
|
||||
totalCount += group.Count
|
||||
categories[i] = *group
|
||||
}
|
||||
|
||||
return TaskTemplatesGroupedResponse{
|
||||
Categories: categories,
|
||||
TotalCount: totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
// parseTags splits a comma-separated tags string into a slice
|
||||
func parseTags(tags string) []string {
|
||||
if tags == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tags, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
106
internal/handlers/task_template_handler.go
Normal file
106
internal/handlers/task_template_handler.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/i18n"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
)
|
||||
|
||||
// TaskTemplateHandler handles task template endpoints
|
||||
type TaskTemplateHandler struct {
|
||||
templateService *services.TaskTemplateService
|
||||
}
|
||||
|
||||
// NewTaskTemplateHandler creates a new task template handler
|
||||
func NewTaskTemplateHandler(templateService *services.TaskTemplateService) *TaskTemplateHandler {
|
||||
return &TaskTemplateHandler{
|
||||
templateService: templateService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTemplates handles GET /api/tasks/templates/
|
||||
// Returns all active task templates as a flat list
|
||||
func (h *TaskTemplateHandler) GetTemplates(c *gin.Context) {
|
||||
templates, err := h.templateService.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplatesGrouped handles GET /api/tasks/templates/grouped/
|
||||
// Returns all templates grouped by category
|
||||
func (h *TaskTemplateHandler) GetTemplatesGrouped(c *gin.Context) {
|
||||
grouped, err := h.templateService.GetGrouped()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, grouped)
|
||||
}
|
||||
|
||||
// SearchTemplates handles GET /api/tasks/templates/search/
|
||||
// Searches templates by query string
|
||||
func (h *TaskTemplateHandler) SearchTemplates(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(query) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query must be at least 2 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
templates, err := h.templateService.Search(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_search_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplatesByCategory handles GET /api/tasks/templates/by-category/:category_id/
|
||||
// Returns templates for a specific category
|
||||
func (h *TaskTemplateHandler) GetTemplatesByCategory(c *gin.Context) {
|
||||
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||
return
|
||||
}
|
||||
|
||||
templates, err := h.templateService.GetByCategory(uint(categoryID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplate handles GET /api/tasks/templates/:id/
|
||||
// Returns a single template by ID
|
||||
func (h *TaskTemplateHandler) GetTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
template, err := h.templateService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.template_not_found")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, template)
|
||||
}
|
||||
@@ -114,6 +114,9 @@
|
||||
"error.failed_to_fetch_task_frequencies": "Failed to fetch task frequencies",
|
||||
"error.failed_to_fetch_task_statuses": "Failed to fetch task statuses",
|
||||
"error.failed_to_fetch_contractor_specialties": "Failed to fetch contractor specialties",
|
||||
"error.failed_to_fetch_templates": "Failed to fetch task templates",
|
||||
"error.failed_to_search_templates": "Failed to search task templates",
|
||||
"error.template_not_found": "Task template not found",
|
||||
|
||||
"push.task_due_soon.title": "Task Due Soon",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
|
||||
|
||||
22
internal/models/task_template.go
Normal file
22
internal/models/task_template.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
// TaskTemplate represents a predefined task template that users can select when creating tasks
|
||||
type TaskTemplate struct {
|
||||
BaseModel
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
CategoryID *uint `gorm:"column:category_id;index" json:"category_id"`
|
||||
Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"`
|
||||
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
|
||||
IconIOS string `gorm:"column:icon_ios;size:100" json:"icon_ios"`
|
||||
IconAndroid string `gorm:"column:icon_android;size:100" json:"icon_android"`
|
||||
Tags string `gorm:"column:tags;type:text" json:"tags"` // Comma-separated tags for search
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskTemplate) TableName() string {
|
||||
return "task_tasktemplate"
|
||||
}
|
||||
123
internal/repositories/task_template_repo.go
Normal file
123
internal/repositories/task_template_repo.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// TaskTemplateRepository handles database operations for task templates
|
||||
type TaskTemplateRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTaskTemplateRepository creates a new task template repository
|
||||
func NewTaskTemplateRepository(db *gorm.DB) *TaskTemplateRepository {
|
||||
return &TaskTemplateRepository{db: db}
|
||||
}
|
||||
|
||||
// GetAll returns all active task templates ordered by category and display order
|
||||
func (r *TaskTemplateRepository) GetAll() ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Where("is_active = ?", true).
|
||||
Order("display_order ASC, title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// GetByCategory returns all active templates for a specific category
|
||||
func (r *TaskTemplateRepository) GetByCategory(categoryID uint) ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Where("is_active = ? AND category_id = ?", true, categoryID).
|
||||
Order("display_order ASC, title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// Search searches templates by title and tags
|
||||
func (r *TaskTemplateRepository) Search(query string) ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
searchTerm := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Where("is_active = ? AND (LOWER(title) LIKE ? OR LOWER(tags) LIKE ?)", true, searchTerm, searchTerm).
|
||||
Order("display_order ASC, title ASC").
|
||||
Limit(20). // Limit search results
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// GetByID returns a single template by ID
|
||||
func (r *TaskTemplateRepository) GetByID(id uint) (*models.TaskTemplate, error) {
|
||||
var template models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
First(&template, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &template, nil
|
||||
}
|
||||
|
||||
// Create creates a new task template
|
||||
func (r *TaskTemplateRepository) Create(template *models.TaskTemplate) error {
|
||||
return r.db.Create(template).Error
|
||||
}
|
||||
|
||||
// Update updates an existing task template
|
||||
func (r *TaskTemplateRepository) Update(template *models.TaskTemplate) error {
|
||||
return r.db.Save(template).Error
|
||||
}
|
||||
|
||||
// Delete hard deletes a task template
|
||||
func (r *TaskTemplateRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.TaskTemplate{}, id).Error
|
||||
}
|
||||
|
||||
// GetAllIncludingInactive returns all templates including inactive ones (for admin)
|
||||
func (r *TaskTemplateRepository) GetAllIncludingInactive() ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Order("display_order ASC, title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// Count returns the total count of active templates
|
||||
func (r *TaskTemplateRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.TaskTemplate{}).Where("is_active = ?", true).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetGroupedByCategory returns templates grouped by category name
|
||||
func (r *TaskTemplateRepository) GetGroupedByCategory() (map[string][]models.TaskTemplate, error) {
|
||||
templates, err := r.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]models.TaskTemplate)
|
||||
for _, t := range templates {
|
||||
categoryName := "Uncategorized"
|
||||
if t.Category != nil {
|
||||
categoryName = t.Category.Name
|
||||
}
|
||||
result[categoryName] = append(result[categoryName], t)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -83,6 +83,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
documentRepo := repositories.NewDocumentRepository(deps.DB)
|
||||
notificationRepo := repositories.NewNotificationRepository(deps.DB)
|
||||
subscriptionRepo := repositories.NewSubscriptionRepository(deps.DB)
|
||||
taskTemplateRepo := repositories.NewTaskTemplateRepository(deps.DB)
|
||||
|
||||
// Initialize services
|
||||
authService := services.NewAuthService(userRepo, cfg)
|
||||
@@ -99,6 +100,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
taskService.SetNotificationService(notificationService)
|
||||
taskService.SetEmailService(deps.EmailService)
|
||||
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
||||
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
|
||||
|
||||
// Initialize middleware
|
||||
authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||
@@ -117,6 +119,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
|
||||
staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService)
|
||||
taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService)
|
||||
|
||||
// Initialize upload handler (if storage service is available)
|
||||
var uploadHandler *handlers.UploadHandler
|
||||
@@ -140,7 +143,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
setupPublicAuthRoutes(api, authHandler)
|
||||
|
||||
// Public data routes (no auth required)
|
||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler)
|
||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
||||
|
||||
// Protected routes (auth required)
|
||||
protected := api.Group("")
|
||||
@@ -220,7 +223,7 @@ func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHa
|
||||
}
|
||||
|
||||
// setupPublicDataRoutes configures public data routes (lookups, static data)
|
||||
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler) {
|
||||
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
|
||||
// Static data routes (public, cached)
|
||||
staticData := api.Group("/static_data")
|
||||
{
|
||||
@@ -241,6 +244,16 @@ func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resi
|
||||
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
||||
api.GET("/tasks/statuses/", taskHandler.GetStatuses)
|
||||
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
||||
|
||||
// Task template routes (public, for app autocomplete)
|
||||
templates := api.Group("/tasks/templates")
|
||||
{
|
||||
templates.GET("/", taskTemplateHandler.GetTemplates)
|
||||
templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped)
|
||||
templates.GET("/search/", taskTemplateHandler.SearchTemplates)
|
||||
templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory)
|
||||
templates.GET("/:id/", taskTemplateHandler.GetTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
// setupResidenceRoutes configures residence routes
|
||||
|
||||
69
internal/services/task_template_service.go
Normal file
69
internal/services/task_template_service.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
)
|
||||
|
||||
// TaskTemplateService handles business logic for task templates
|
||||
type TaskTemplateService struct {
|
||||
templateRepo *repositories.TaskTemplateRepository
|
||||
}
|
||||
|
||||
// NewTaskTemplateService creates a new task template service
|
||||
func NewTaskTemplateService(templateRepo *repositories.TaskTemplateRepository) *TaskTemplateService {
|
||||
return &TaskTemplateService{
|
||||
templateRepo: templateRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns all active task templates
|
||||
func (s *TaskTemplateService) GetAll() ([]responses.TaskTemplateResponse, error) {
|
||||
templates, err := s.templateRepo.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// GetGrouped returns all templates grouped by category
|
||||
func (s *TaskTemplateService) GetGrouped() (responses.TaskTemplatesGroupedResponse, error) {
|
||||
templates, err := s.templateRepo.GetAll()
|
||||
if err != nil {
|
||||
return responses.TaskTemplatesGroupedResponse{}, err
|
||||
}
|
||||
return responses.NewTaskTemplatesGroupedResponse(templates), nil
|
||||
}
|
||||
|
||||
// Search searches templates by query string
|
||||
func (s *TaskTemplateService) Search(query string) ([]responses.TaskTemplateResponse, error) {
|
||||
templates, err := s.templateRepo.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// GetByCategory returns templates for a specific category
|
||||
func (s *TaskTemplateService) GetByCategory(categoryID uint) ([]responses.TaskTemplateResponse, error) {
|
||||
templates, err := s.templateRepo.GetByCategory(categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// GetByID returns a single template by ID
|
||||
func (s *TaskTemplateService) GetByID(id uint) (*responses.TaskTemplateResponse, error) {
|
||||
template, err := s.templateRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := responses.NewTaskTemplateResponse(template)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Count returns the total count of active templates
|
||||
func (s *TaskTemplateService) Count() (int64, error) {
|
||||
return s.templateRepo.Count()
|
||||
}
|
||||
106
seeds/003_task_templates.sql
Normal file
106
seeds/003_task_templates.sql
Normal file
@@ -0,0 +1,106 @@
|
||||
-- Seed task templates for MyCrib
|
||||
-- Run with: ./dev.sh seed (after running 001_lookups.sql)
|
||||
--
|
||||
-- Category IDs (from 001_lookups.sql):
|
||||
-- 1 = Appliances, 2 = Cleaning, 3 = Electrical, 4 = Exterior
|
||||
-- 5 = General, 6 = HVAC, 7 = Interior, 8 = Pest Control, 9 = Plumbing, 10 = Safety
|
||||
--
|
||||
-- Frequency IDs (from 001_lookups.sql):
|
||||
-- 1 = Once, 2 = Daily, 3 = Weekly, 4 = Bi-Weekly, 5 = Monthly
|
||||
-- 6 = Quarterly, 7 = Semi-Annually, 8 = Annually
|
||||
|
||||
-- Task Templates
|
||||
INSERT INTO task_tasktemplate (id, created_at, updated_at, title, description, category_id, frequency_id, icon_ios, icon_android, tags, display_order, is_active)
|
||||
VALUES
|
||||
-- PLUMBING (category_id = 9)
|
||||
(1, NOW(), NOW(), 'Check/Replace Water Heater Anode Rod', 'Inspect anode rod for corrosion and replace if more than 50% depleted', 9, 8, 'wrench.and.screwdriver.fill', 'Build', 'water heater,anode,corrosion,tank', 1, true),
|
||||
(2, NOW(), NOW(), 'Test Interior Water Shutoffs', 'Turn each shutoff valve to ensure it works properly', 9, 8, 'spigot.fill', 'Water', 'shutoff,valve,water,emergency', 2, true),
|
||||
(3, NOW(), NOW(), 'Test Water Meter Shutoff', 'Ensure main water shutoff at meter is functional', 9, 8, 'gauge.with.dots.needle.33percent', 'Speed', 'meter,shutoff,main,emergency', 3, true),
|
||||
(4, NOW(), NOW(), 'Check Water Meter for Leaks', 'Turn off all water and check if meter is still moving', 9, 5, 'drop.triangle.fill', 'WaterDrop', 'leak,meter,water,detection', 4, true),
|
||||
(5, NOW(), NOW(), 'Run Drain Cleaner', 'Use enzyme-based drain cleaner to prevent clogs', 9, 5, 'arrow.down.to.line.circle.fill', 'VerticalAlignBottom', 'drain,clog,cleaner,pipes', 5, true),
|
||||
(6, NOW(), NOW(), 'Test Water Heater Pressure Relief Valve', 'Lift lever to release some water and ensure valve works', 9, 8, 'bolt.horizontal.fill', 'Compress', 'water heater,pressure,safety,valve', 6, true),
|
||||
(7, NOW(), NOW(), 'Replace Water Filters', 'Replace whole-house or point-of-use water filters', 9, 6, 'drop.fill', 'Water', 'filter,water,replacement,drinking', 7, true),
|
||||
|
||||
-- SAFETY (category_id = 10)
|
||||
(8, NOW(), NOW(), 'Test Smoke Detectors', 'Press test button on each detector and replace batteries if needed', 10, 5, 'smoke.fill', 'Sensors', 'smoke,detector,safety,fire', 8, true),
|
||||
(9, NOW(), NOW(), 'Test Carbon Monoxide Detectors', 'Press test button and verify alarm sounds', 10, 5, 'sensor.tag.radiowaves.forward.fill', 'Air', 'carbon monoxide,detector,safety,gas', 9, true),
|
||||
(10, NOW(), NOW(), 'Inspect Fire Extinguisher', 'Check pressure gauge and ensure pin is intact', 10, 8, 'flame.fill', 'LocalFireDepartment', 'fire,extinguisher,safety,inspection', 10, true),
|
||||
(11, NOW(), NOW(), 'Replace Smoke Detector Batteries', 'Replace batteries in all smoke and CO detectors', 10, 8, 'battery.100', 'Battery5Bar', 'battery,smoke,detector,safety', 11, true),
|
||||
(12, NOW(), NOW(), 'Test GFCI Outlets', 'Press test/reset buttons on all GFCI outlets', 10, 5, 'poweroutlet.type.b', 'ElectricalServices', 'gfci,outlet,safety,electrical', 12, true),
|
||||
(13, NOW(), NOW(), 'Check Emergency Exits', 'Verify all exits are clear and doors open freely', 10, 6, 'door.left.hand.open', 'DoorFront', 'emergency,exit,safety,door', 13, true),
|
||||
|
||||
-- HVAC (category_id = 6)
|
||||
(14, NOW(), NOW(), 'Replace HVAC Filter', 'Replace air filter to maintain air quality and efficiency', 6, 5, 'air.purifier', 'Air', 'hvac,filter,air,replacement', 14, true),
|
||||
(15, NOW(), NOW(), 'Clean AC Condensate Drain', 'Clear drain line to prevent clogs and water damage', 6, 6, 'drop.degreesign.fill', 'WaterDamage', 'ac,condensate,drain,clog', 15, true),
|
||||
(16, NOW(), NOW(), 'Schedule HVAC Service', 'Annual professional maintenance for heating and cooling system', 6, 8, 'wrench.and.screwdriver', 'Build', 'hvac,service,maintenance,professional', 16, true),
|
||||
(17, NOW(), NOW(), 'Clean Air Vents and Registers', 'Remove dust and debris from all vents', 6, 6, 'wind', 'AirPurifier', 'vent,register,dust,cleaning', 17, true),
|
||||
(18, NOW(), NOW(), 'Test Thermostat', 'Verify thermostat accurately controls heating and cooling', 6, 7, 'thermometer.sun.fill', 'Thermostat', 'thermostat,hvac,test,calibration', 18, true),
|
||||
(19, NOW(), NOW(), 'Inspect Ductwork', 'Check for leaks, damage, or disconnected sections', 6, 8, 'rectangle.3.group', 'Hvac', 'duct,ductwork,leak,inspection', 19, true),
|
||||
(20, NOW(), NOW(), 'Clean Outdoor AC Unit', 'Remove debris and clean condenser coils', 6, 8, 'fan', 'WindPower', 'ac,condenser,outdoor,cleaning', 20, true),
|
||||
(21, NOW(), NOW(), 'Check Refrigerant Levels', 'Have professional check AC refrigerant', 6, 8, 'snowflake', 'AcUnit', 'refrigerant,ac,freon,professional', 21, true),
|
||||
(22, NOW(), NOW(), 'Inspect Heat Pump', 'Check heat pump operation and clean coils', 6, 7, 'heat.waves', 'HeatPump', 'heat pump,inspection,coils,maintenance', 22, true),
|
||||
|
||||
-- APPLIANCES (category_id = 1)
|
||||
(23, NOW(), NOW(), 'Clean Refrigerator Coils', 'Vacuum condenser coils to improve efficiency', 1, 7, 'refrigerator.fill', 'Kitchen', 'refrigerator,coils,cleaning,efficiency', 23, true),
|
||||
(24, NOW(), NOW(), 'Clean Dishwasher Filter', 'Remove and clean the dishwasher filter', 1, 5, 'dishwasher.fill', 'LocalLaundryService', 'dishwasher,filter,cleaning', 24, true),
|
||||
(25, NOW(), NOW(), 'Clean Washing Machine', 'Run cleaning cycle or use washing machine cleaner', 1, 5, 'washer.fill', 'LocalLaundryService', 'washing machine,cleaning,maintenance', 25, true),
|
||||
(26, NOW(), NOW(), 'Clean Dryer Vent', 'Clean lint from dryer vent and ductwork', 1, 8, 'dryer.fill', 'LocalLaundryService', 'dryer,vent,lint,fire hazard', 26, true),
|
||||
(27, NOW(), NOW(), 'Clean Range Hood Filter', 'Remove and clean grease filter', 1, 6, 'stove.fill', 'Microwave', 'range hood,filter,grease,cleaning', 27, true),
|
||||
(28, NOW(), NOW(), 'Descale Coffee Maker', 'Run descaling solution through coffee maker', 1, 5, 'cup.and.saucer.fill', 'Coffee', 'coffee,descale,cleaning,appliance', 28, true),
|
||||
(29, NOW(), NOW(), 'Clean Garbage Disposal', 'Clean and deodorize garbage disposal', 1, 5, 'trash.fill', 'Delete', 'garbage disposal,cleaning,deodorize', 29, true),
|
||||
(30, NOW(), NOW(), 'Clean Oven', 'Run self-clean cycle or manually clean oven', 1, 6, 'oven.fill', 'Microwave', 'oven,cleaning,grease', 30, true),
|
||||
(31, NOW(), NOW(), 'Check Refrigerator Seals', 'Inspect door gaskets for cracks or gaps', 1, 7, 'seal.fill', 'DoorSliding', 'refrigerator,seal,gasket,inspection', 31, true),
|
||||
|
||||
-- EXTERIOR (category_id = 4)
|
||||
(32, NOW(), NOW(), 'Clean Gutters', 'Remove leaves and debris from gutters and downspouts', 4, 7, 'house.fill', 'Roofing', 'gutter,cleaning,leaves,debris', 32, true),
|
||||
(33, NOW(), NOW(), 'Inspect Roof', 'Check for damaged, missing, or loose shingles', 4, 8, 'house.circle.fill', 'Roofing', 'roof,shingles,inspection,damage', 33, true),
|
||||
(34, NOW(), NOW(), 'Power Wash Exterior', 'Clean siding, walkways, and driveway', 4, 8, 'water.waves', 'CleaningServices', 'power wash,siding,driveway,cleaning', 34, true),
|
||||
(35, NOW(), NOW(), 'Seal Driveway', 'Apply sealant to asphalt or concrete driveway', 4, 8, 'road.lanes', 'RoundaboutRight', 'driveway,seal,asphalt,concrete', 35, true),
|
||||
(36, NOW(), NOW(), 'Inspect Foundation', 'Check for cracks or signs of settling', 4, 8, 'building.2.fill', 'Foundation', 'foundation,cracks,inspection,settling', 36, true),
|
||||
(37, NOW(), NOW(), 'Clean Window Exteriors', 'Wash exterior window surfaces', 4, 7, 'window.horizontal', 'Window', 'window,exterior,cleaning,glass', 37, true),
|
||||
(38, NOW(), NOW(), 'Inspect Deck/Patio', 'Check for rot, loose boards, or damage', 4, 8, 'rectangle.split.3x1.fill', 'Deck', 'deck,patio,inspection,rot', 38, true),
|
||||
(39, NOW(), NOW(), 'Stain/Seal Deck', 'Apply stain or sealant to protect wood deck', 4, 8, 'paintbrush.fill', 'FormatPaint', 'deck,stain,seal,wood', 39, true),
|
||||
|
||||
-- LAWN & GARDEN (category_id = 4, but could be General category = 5)
|
||||
(40, NOW(), NOW(), 'Mow Lawn', 'Cut grass to appropriate height', 4, 3, 'leaf.fill', 'Grass', 'lawn,mowing,grass,yard', 40, true),
|
||||
(41, NOW(), NOW(), 'Trim Trees/Shrubs', 'Prune overgrown branches and shape shrubs', 4, 8, 'tree.fill', 'Park', 'tree,shrub,pruning,trimming', 41, true),
|
||||
(42, NOW(), NOW(), 'Fertilize Lawn', 'Apply seasonal fertilizer to lawn', 4, 6, 'drop.fill', 'Opacity', 'fertilizer,lawn,grass,seasonal', 42, true),
|
||||
(43, NOW(), NOW(), 'Aerate Lawn', 'Aerate soil to improve grass health', 4, 8, 'square.grid.3x3.fill', 'GridOn', 'aerate,lawn,soil,grass', 43, true),
|
||||
(44, NOW(), NOW(), 'Winterize Irrigation', 'Blow out sprinkler lines before winter', 4, 8, 'snowflake', 'AcUnit', 'irrigation,sprinkler,winterize,freeze', 44, true),
|
||||
(45, NOW(), NOW(), 'Mulch Garden Beds', 'Add fresh mulch to garden beds', 4, 8, 'leaf.fill', 'Forest', 'mulch,garden,beds,landscaping', 45, true),
|
||||
|
||||
-- ELECTRICAL (category_id = 3)
|
||||
(46, NOW(), NOW(), 'Test Surge Protectors', 'Check indicator lights on surge protectors', 3, 8, 'bolt.fill', 'ElectricBolt', 'surge,protector,electrical,safety', 46, true),
|
||||
(47, NOW(), NOW(), 'Check Circuit Breaker Panel', 'Inspect for signs of damage or overheating', 3, 8, 'bolt.square.fill', 'ElectricalServices', 'circuit,breaker,panel,inspection', 47, true),
|
||||
(48, NOW(), NOW(), 'Replace Outdoor Light Bulbs', 'Check and replace burned out exterior lights', 3, 6, 'lightbulb.fill', 'LightbulbOutline', 'light,bulb,outdoor,replacement', 48, true),
|
||||
(49, NOW(), NOW(), 'Test Outdoor Lighting Timer', 'Verify timer settings and functionality', 3, 7, 'clock.fill', 'Schedule', 'timer,lighting,outdoor,test', 49, true),
|
||||
|
||||
-- INTERIOR (category_id = 7)
|
||||
(50, NOW(), NOW(), 'Deep Clean Carpets', 'Steam clean or shampoo carpets', 7, 8, 'rectangle.and.hand.point.up.left.fill', 'Carpet', 'carpet,cleaning,steam,deep clean', 50, true),
|
||||
(51, NOW(), NOW(), 'Clean Window Interiors', 'Wash interior window surfaces and tracks', 7, 6, 'window.horizontal', 'Window', 'window,interior,cleaning,tracks', 51, true),
|
||||
(52, NOW(), NOW(), 'Check Caulking', 'Inspect and repair caulk around tubs, showers, sinks', 7, 8, 'sealant.fill', 'LineWeight', 'caulk,bathroom,kitchen,seal', 52, true),
|
||||
(53, NOW(), NOW(), 'Lubricate Door Hinges', 'Apply lubricant to squeaky hinges', 7, 8, 'door.left.hand.closed', 'MeetingRoom', 'door,hinge,lubricant,squeak', 53, true),
|
||||
(54, NOW(), NOW(), 'Touch Up Paint', 'Fix scuffs and marks on walls', 7, 8, 'paintbrush.fill', 'ImagesearchRoller', 'paint,walls,touch up,scuffs', 54, true),
|
||||
(55, NOW(), NOW(), 'Replace Air Fresheners', 'Change out air fresheners throughout home', 7, 5, 'wind', 'Air', 'air freshener,scent,home,fragrance', 55, true),
|
||||
|
||||
-- SEASONAL (category_id = 5 General)
|
||||
(56, NOW(), NOW(), 'Reverse Ceiling Fan Direction', 'Change rotation for heating vs cooling season', 5, 7, 'fanblades.fill', 'WindPower', 'ceiling fan,direction,seasonal,hvac', 56, true),
|
||||
(57, NOW(), NOW(), 'Store/Retrieve Seasonal Items', 'Rotate seasonal decorations and equipment', 5, 7, 'archivebox.fill', 'Archive', 'seasonal,storage,decorations,rotation', 57, true),
|
||||
(58, NOW(), NOW(), 'Check Weather Stripping', 'Inspect and replace worn weather stripping on doors/windows', 5, 8, 'wind', 'Air', 'weather stripping,door,window,insulation', 58, true),
|
||||
(59, NOW(), NOW(), 'Service Snow Blower', 'Prepare snow blower for winter season', 5, 8, 'snowflake', 'AcUnit', 'snow blower,service,winter,maintenance', 59, true),
|
||||
(60, NOW(), NOW(), 'Service Lawn Mower', 'Change oil, sharpen blade, replace spark plug', 5, 8, 'leaf.fill', 'Grass', 'lawn mower,service,maintenance,spring', 60, true)
|
||||
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
category_id = EXCLUDED.category_id,
|
||||
frequency_id = EXCLUDED.frequency_id,
|
||||
icon_ios = EXCLUDED.icon_ios,
|
||||
icon_android = EXCLUDED.icon_android,
|
||||
tags = EXCLUDED.tags,
|
||||
display_order = EXCLUDED.display_order,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Reset sequence
|
||||
SELECT setval('task_tasktemplate_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_tasktemplate), false);
|
||||
Reference in New Issue
Block a user