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:
Trey t
2025-12-05 09:07:53 -06:00
parent e824c90877
commit bbf3999c79
17 changed files with 1634 additions and 4 deletions

View File

@@ -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>

View 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 &quot;Seed Lookup Data&quot; 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 &quot;{template.title}&quot;.</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>
);
}

View File

@@ -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 },
];

View File

@@ -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
View 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

View File

@@ -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

View 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
}

View File

@@ -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)
}

View File

@@ -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{},

View 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
}

View 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)
}

View File

@@ -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}}",

View 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"
}

View 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
}

View File

@@ -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

View 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()
}

View 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);