From 5e95dcd0153614ba2bc7553c709b1aca2120e274 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 28 Nov 2025 11:07:51 -0600 Subject: [PATCH] Add multi-image support for task completions and documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TaskCompletionImage and DocumentImage models with one-to-many relationships - Update admin panel to display images for completions and documents - Add image arrays to API request/response DTOs - Update repositories with Preload("Images") for eager loading - Fix seed SQL execution to use raw SQL instead of prepared statements - Fix table names in seed file (admin_users, push_notifications_*) - Add comprehensive seed test data with 34 completion images and 24 document images - Add subscription limitations admin feature with toggle - Update admin sidebar with limitations link 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/(dashboard)/completions/[id]/page.tsx | 73 +- .../src/app/(dashboard)/completions/page.tsx | 45 +- .../app/(dashboard)/documents/[id]/client.tsx | 71 +- .../src/app/(dashboard)/limitations/page.tsx | 291 ++++++++ .../(dashboard)/limitations/triggers/page.tsx | 508 +++++++++++++ admin/src/app/(dashboard)/settings/page.tsx | 73 +- admin/src/components/app-sidebar.tsx | 29 + admin/src/lib/api.ts | 128 +++- admin/src/types/models.ts | 8 + internal/admin/dto/responses.go | 34 +- internal/admin/handlers/completion_handler.go | 51 +- internal/admin/handlers/document_handler.go | 18 +- .../admin/handlers/limitations_handler.go | 480 ++++++++++++ internal/admin/handlers/settings_handler.go | 19 +- internal/admin/routes.go | 22 + internal/database/database.go | 2 + internal/dto/requests/document.go | 1 + internal/dto/requests/task.go | 9 +- internal/dto/responses/document.go | 26 +- internal/dto/responses/task.go | 35 +- internal/handlers/subscription_handler.go | 11 + internal/handlers/task_handler.go | 2 +- internal/models/document.go | 36 +- internal/models/task.go | 18 +- internal/repositories/document_repo.go | 22 +- internal/repositories/task_repo.go | 15 + internal/router/router.go | 1 + internal/services/document_service.go | 14 + internal/services/subscription_service.go | 150 +++- internal/services/task_service.go | 17 +- seeds/002_test_data.sql | 706 +++++++++++++++--- 31 files changed, 2595 insertions(+), 320 deletions(-) create mode 100644 admin/src/app/(dashboard)/limitations/page.tsx create mode 100644 admin/src/app/(dashboard)/limitations/triggers/page.tsx create mode 100644 internal/admin/handlers/limitations_handler.go diff --git a/admin/src/app/(dashboard)/completions/[id]/page.tsx b/admin/src/app/(dashboard)/completions/[id]/page.tsx index 7e875d8..4fb6663 100644 --- a/admin/src/app/(dashboard)/completions/[id]/page.tsx +++ b/admin/src/app/(dashboard)/completions/[id]/page.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil } from 'lucide-react'; +import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil, Star } from 'lucide-react'; import { toast } from 'sonner'; import { completionsApi } from '@/lib/api'; @@ -188,6 +188,26 @@ export default function CompletionDetailPage() { Not recorded )} +
+
Rating
+ {completion.rating ? ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} + ({completion.rating}/5) +
+ ) : ( + Not rated + )} +
Record Created
{new Date(completion.created_at).toLocaleString()}
@@ -210,33 +230,40 @@ export default function CompletionDetailPage() { - {/* Photo */} - {completion.photo_url && ( + {/* Photos */} + {completion.images && completion.images.length > 0 && ( - Completion Photo + Completion Photos ({completion.images.length}) Photo evidence of task completion -
-
- Completion photo -
- +
+ {completion.images.map((image, index) => ( +
+
+ {image.caption +
+ {image.caption && ( +

{image.caption}

+ )} + +
+ ))}
diff --git a/admin/src/app/(dashboard)/completions/page.tsx b/admin/src/app/(dashboard)/completions/page.tsx index 50dca45..9a56976 100644 --- a/admin/src/app/(dashboard)/completions/page.tsx +++ b/admin/src/app/(dashboard)/completions/page.tsx @@ -252,34 +252,43 @@ export default function CompletionsPage() { )} - {completion.photo_url ? ( + {completion.images && completion.images.length > 0 ? ( - Completion Photo + Completion Photos ({completion.images.length}) -
- Completion photo +
+ {completion.images.map((image, index) => ( +
+
+ {image.caption +
+ {image.caption && ( +

{image.caption}

+ )} + + + Open in new tab + +
+ ))}
- - - Open in new tab -
) : ( diff --git a/admin/src/app/(dashboard)/documents/[id]/client.tsx b/admin/src/app/(dashboard)/documents/[id]/client.tsx index b97f130..c0d9dba 100644 --- a/admin/src/app/(dashboard)/documents/[id]/client.tsx +++ b/admin/src/app/(dashboard)/documents/[id]/client.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil } from 'lucide-react'; +import Image from 'next/image'; +import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil, ImageIcon, X } from 'lucide-react'; import { toast } from 'sonner'; import { documentsApi } from '@/lib/api'; @@ -16,12 +18,17 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; +import { + Dialog, + DialogContent, +} from '@/components/ui/dialog'; export function DocumentDetailClient() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const documentId = Number(params.id); + const [selectedImage, setSelectedImage] = useState(null); const { data: document, isLoading, error } = useQuery({ queryKey: ['document', documentId], @@ -181,6 +188,68 @@ export function DocumentDetailClient() {
+ + {/* Document Images */} + {document.images && document.images.length > 0 && ( + + + + + Images ({document.images.length}) + + Photos and images attached to this document + + +
+ {document.images.map((image) => ( +
setSelectedImage(image.image_url)} + > + {image.caption + {image.caption && ( +
+ {image.caption} +
+ )} +
+ ))} +
+
+
+ )} + + {/* Image Modal */} + setSelectedImage(null)}> + + {selectedImage && ( +
+ + Document image +
+ )} +
+
); } diff --git a/admin/src/app/(dashboard)/limitations/page.tsx b/admin/src/app/(dashboard)/limitations/page.tsx new file mode 100644 index 0000000..ba40d4f --- /dev/null +++ b/admin/src/app/(dashboard)/limitations/page.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Shield, Layers, Infinity } from 'lucide-react'; + +import { limitationsApi, type TierLimits } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { toast } from 'sonner'; +import { useState, useEffect } from 'react'; + +interface TierLimitsFormData { + properties_limit: string; + tasks_limit: string; + contractors_limit: string; + documents_limit: string; +} + +function TierLimitsCard({ + tier, + limits, + onSave +}: { + tier: 'free' | 'pro'; + limits?: TierLimits; + onSave: (tier: 'free' | 'pro', data: TierLimitsFormData) => void; +}) { + const [formData, setFormData] = useState({ + properties_limit: '', + tasks_limit: '', + contractors_limit: '', + documents_limit: '', + }); + + useEffect(() => { + if (limits) { + setFormData({ + properties_limit: limits.properties_limit?.toString() ?? '', + tasks_limit: limits.tasks_limit?.toString() ?? '', + contractors_limit: limits.contractors_limit?.toString() ?? '', + documents_limit: limits.documents_limit?.toString() ?? '', + }); + } + }, [limits]); + + const handleChange = (field: keyof TierLimitsFormData, value: string) => { + // Only allow numbers or empty string + if (value === '' || /^\d+$/.test(value)) { + setFormData(prev => ({ ...prev, [field]: value })); + } + }; + + return ( + + + + + {tier} Tier Limits + + + {tier === 'free' + ? 'Set resource limits for free tier users. Leave empty for unlimited.' + : 'Set resource limits for Pro tier users. Typically left empty (unlimited).' + } + + + +
+
+ +
+ handleChange('properties_limit', e.target.value)} + /> + {formData.properties_limit === '' && ( + + )} +
+
+
+ +
+ handleChange('tasks_limit', e.target.value)} + /> + {formData.tasks_limit === '' && ( + + )} +
+
+
+ +
+ handleChange('contractors_limit', e.target.value)} + /> + {formData.contractors_limit === '' && ( + + )} +
+
+
+ +
+ handleChange('documents_limit', e.target.value)} + /> + {formData.documents_limit === '' && ( + + )} +
+
+
+ +
+
+ ); +} + +export default function LimitationsPage() { + const queryClient = useQueryClient(); + + const { data: settings, isLoading: settingsLoading } = useQuery({ + queryKey: ['limitations-settings'], + queryFn: limitationsApi.getSettings, + }); + + const { data: tierLimits, isLoading: limitsLoading } = useQuery({ + queryKey: ['tier-limits'], + queryFn: limitationsApi.listTierLimits, + }); + + const updateSettingsMutation = useMutation({ + mutationFn: limitationsApi.updateSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['limitations-settings'] }); + toast.success('Limitations settings updated'); + }, + onError: () => { + toast.error('Failed to update limitations settings'); + }, + }); + + const updateTierLimitsMutation = useMutation({ + mutationFn: ({ tier, data }: { tier: 'free' | 'pro'; data: TierLimitsFormData }) => { + return limitationsApi.updateTierLimits(tier, { + properties_limit: data.properties_limit === '' ? null : parseInt(data.properties_limit), + tasks_limit: data.tasks_limit === '' ? null : parseInt(data.tasks_limit), + contractors_limit: data.contractors_limit === '' ? null : parseInt(data.contractors_limit), + documents_limit: data.documents_limit === '' ? null : parseInt(data.documents_limit), + }); + }, + onSuccess: (_, { tier }) => { + queryClient.invalidateQueries({ queryKey: ['tier-limits'] }); + toast.success(`${tier.charAt(0).toUpperCase() + tier.slice(1)} tier limits updated`); + }, + onError: () => { + toast.error('Failed to update tier limits'); + }, + }); + + const handleLimitationsToggle = () => { + if (settings) { + updateSettingsMutation.mutate({ + enable_limitations: !settings.enable_limitations, + }); + } + }; + + const handleSaveTierLimits = (tier: 'free' | 'pro', data: TierLimitsFormData) => { + updateTierLimitsMutation.mutate({ tier, data }); + }; + + const freeLimits = tierLimits?.find(l => l.tier === 'free'); + const proLimits = tierLimits?.find(l => l.tier === 'pro'); + + if (settingsLoading || limitsLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + return ( +
+
+

Subscription Limitations

+

+ Configure tier-based resource limits for users +

+
+ + {/* Enable Limitations Toggle */} + + + + + Enable Limitations + + + Control whether tier-based limitations are enforced for users. When disabled, all users have full access regardless of their subscription tier. + + + +
+
+ +

+ {settings?.enable_limitations + ? 'Limitations are currently ENABLED. Free tier users have restricted access.' + : 'Limitations are currently DISABLED. All users have full access.' + } +

+
+ +
+
+
+ + {/* Tier Limits */} +
+ + +
+ + {/* Info Box */} + + +
+

How limits work:

+
    +
  • Empty fields mean unlimited access for that resource
  • +
  • A value of 0 means no access (blocked)
  • +
  • Limits only apply when "Enable Limitations" is turned ON
  • +
  • Pro tier users typically have unlimited access
  • +
  • Changes take effect immediately for all users
  • +
+
+
+
+
+ ); +} diff --git a/admin/src/app/(dashboard)/limitations/triggers/page.tsx b/admin/src/app/(dashboard)/limitations/triggers/page.tsx new file mode 100644 index 0000000..75716c5 --- /dev/null +++ b/admin/src/app/(dashboard)/limitations/triggers/page.tsx @@ -0,0 +1,508 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Sparkles, Plus, Pencil, Trash2, Eye, EyeOff, Code } from 'lucide-react'; +import { useState, useEffect } from 'react'; + +import { limitationsApi, type UpgradeTrigger, type TriggerKeyOption, type CreateUpgradeTriggerRequest, type UpdateUpgradeTriggerRequest } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; + +interface TriggerFormData { + trigger_key: string; + title: string; + message: string; + promo_html: string; + button_text: string; + is_active: boolean; +} + +const emptyFormData: TriggerFormData = { + trigger_key: '', + title: '', + message: '', + promo_html: '', + button_text: 'Upgrade to Pro', + is_active: true, +}; + +function TriggerFormDialog({ + trigger, + triggerKeys, + existingTriggerKeys, + onSave, + onClose, + isOpen, +}: { + trigger?: UpgradeTrigger; + triggerKeys: TriggerKeyOption[]; + existingTriggerKeys: string[]; + onSave: (data: CreateUpgradeTriggerRequest | UpdateUpgradeTriggerRequest, id?: number) => void; + onClose: () => void; + isOpen: boolean; +}) { + const [formData, setFormData] = useState(emptyFormData); + const [showPreview, setShowPreview] = useState(false); + + // Sync form data when trigger changes or dialog opens + useEffect(() => { + if (isOpen) { + if (trigger) { + setFormData({ + trigger_key: trigger.trigger_key || '', + title: trigger.title || '', + message: trigger.message || '', + promo_html: trigger.promo_html || '', + button_text: trigger.button_text || 'Upgrade to Pro', + is_active: trigger.is_active ?? true, + }); + } else { + setFormData(emptyFormData); + } + setShowPreview(false); + } + }, [trigger, isOpen]); + + // For editing: always include the current trigger's key in available options + // For creating: filter out already used keys + const availableKeys = trigger + ? triggerKeys // When editing, show all keys (user can change to any unused key) + : triggerKeys.filter(k => !existingTriggerKeys.includes(k.key)); + + const handleSave = () => { + if (!formData.trigger_key || !formData.title || !formData.message) { + toast.error('Please fill in all required fields'); + return; + } + + if (trigger) { + onSave(formData, trigger.id); + } else { + onSave(formData); + } + onClose(); + }; + + return ( + !open && onClose()}> + + + {trigger ? 'Edit' : 'Create'} Upgrade Trigger + + Configure the upgrade prompt that appears when users hit this trigger point. + + + +
+
+ + +
+ +
+ + setFormData(prev => ({ ...prev, title: e.target.value }))} + placeholder="e.g., Unlock More Properties" + /> +
+ +
+ +