Add multi-image support for task completions and documents

- 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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-28 11:07:51 -06:00
parent 3cd222c048
commit 5e95dcd015
31 changed files with 2595 additions and 320 deletions

View File

@@ -3,7 +3,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; 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 { toast } from 'sonner';
import { completionsApi } from '@/lib/api'; import { completionsApi } from '@/lib/api';
@@ -188,6 +188,26 @@ export default function CompletionDetailPage() {
<span className="text-muted-foreground">Not recorded</span> <span className="text-muted-foreground">Not recorded</span>
)} )}
</div> </div>
<div>
<div className="text-sm font-medium text-muted-foreground">Rating</div>
{completion.rating ? (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-5 w-5 ${
star <= completion.rating!
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300'
}`}
/>
))}
<span className="ml-2 text-sm">({completion.rating}/5)</span>
</div>
) : (
<span className="text-muted-foreground">Not rated</span>
)}
</div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Record Created</div> <div className="text-sm font-medium text-muted-foreground">Record Created</div>
<div>{new Date(completion.created_at).toLocaleString()}</div> <div>{new Date(completion.created_at).toLocaleString()}</div>
@@ -210,33 +230,40 @@ export default function CompletionDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Photo */} {/* Photos */}
{completion.photo_url && ( {completion.images && completion.images.length > 0 && (
<Card className="md:col-span-2"> <Card className="md:col-span-2">
<CardHeader> <CardHeader>
<CardTitle>Completion Photo</CardTitle> <CardTitle>Completion Photos ({completion.images.length})</CardTitle>
<CardDescription>Photo evidence of task completion</CardDescription> <CardDescription>Photo evidence of task completion</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="grid gap-6 md:grid-cols-2">
<div className="relative max-w-2xl mx-auto"> {completion.images.map((image, index) => (
<img <div key={image.id || index} className="space-y-2">
src={completion.photo_url} <div className="relative">
alt="Completion photo" <img
className="rounded-lg border object-contain w-full" src={image.image_url}
/> alt={image.caption || `Completion photo ${index + 1}`}
</div> className="rounded-lg border object-contain w-full"
<div className="flex justify-center"> />
<a </div>
href={completion.photo_url} {image.caption && (
target="_blank" <p className="text-sm text-muted-foreground text-center">{image.caption}</p>
rel="noopener noreferrer" )}
className="flex items-center gap-1 text-sm text-blue-600 hover:underline" <div className="flex justify-center">
> <a
<ExternalLink className="h-4 w-4" /> href={image.image_url}
Open full size in new tab target="_blank"
</a> rel="noopener noreferrer"
</div> className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
>
<ExternalLink className="h-4 w-4" />
Open full size
</a>
</div>
</div>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -252,34 +252,43 @@ export default function CompletionsPage() {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{completion.photo_url ? ( {completion.images && completion.images.length > 0 ? (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<ImageIcon className="h-4 w-4 mr-1" /> <ImageIcon className="h-4 w-4 mr-1" />
View {completion.images.length} {completion.images.length === 1 ? 'Photo' : 'Photos'}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Completion Photo</DialogTitle> <DialogTitle>Completion Photos ({completion.images.length})</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="relative aspect-video"> <div className="space-y-4 max-h-[60vh] overflow-y-auto">
<img {completion.images.map((image, index) => (
src={completion.photo_url} <div key={image.id || index} className="space-y-2">
alt="Completion photo" <div className="relative aspect-video">
className="object-contain w-full h-full rounded-lg" <img
/> src={image.image_url}
alt={image.caption || `Completion photo ${index + 1}`}
className="object-contain w-full h-full rounded-lg"
/>
</div>
{image.caption && (
<p className="text-sm text-muted-foreground">{image.caption}</p>
)}
<a
href={image.image_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
>
<ExternalLink className="h-3 w-3" />
Open in new tab
</a>
</div>
))}
</div> </div>
<a
href={completion.photo_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
>
<ExternalLink className="h-3 w-3" />
Open in new tab
</a>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) : ( ) : (

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; 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 { toast } from 'sonner';
import { documentsApi } from '@/lib/api'; import { documentsApi } from '@/lib/api';
@@ -16,12 +18,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import {
Dialog,
DialogContent,
} from '@/components/ui/dialog';
export function DocumentDetailClient() { export function DocumentDetailClient() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const documentId = Number(params.id); const documentId = Number(params.id);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const { data: document, isLoading, error } = useQuery({ const { data: document, isLoading, error } = useQuery({
queryKey: ['document', documentId], queryKey: ['document', documentId],
@@ -181,6 +188,68 @@ export function DocumentDetailClient() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Document Images */}
{document.images && document.images.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Images ({document.images.length})
</CardTitle>
<CardDescription>Photos and images attached to this document</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{document.images.map((image) => (
<div
key={image.id}
className="relative aspect-square rounded-lg overflow-hidden border cursor-pointer hover:ring-2 hover:ring-primary transition-all"
onClick={() => setSelectedImage(image.image_url)}
>
<Image
src={image.image_url}
alt={image.caption || 'Document image'}
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
/>
{image.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1 truncate">
{image.caption}
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Image Modal */}
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
<DialogContent className="max-w-4xl p-0 overflow-hidden">
{selectedImage && (
<div className="relative">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 z-10 bg-black/50 hover:bg-black/70 text-white"
onClick={() => setSelectedImage(null)}
>
<X className="h-4 w-4" />
</Button>
<Image
src={selectedImage}
alt="Document image"
width={1200}
height={800}
className="w-full h-auto max-h-[80vh] object-contain"
/>
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -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<TierLimitsFormData>({
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 capitalize">
<Layers className="h-5 w-5" />
{tier} Tier Limits
</CardTitle>
<CardDescription>
{tier === 'free'
? 'Set resource limits for free tier users. Leave empty for unlimited.'
: 'Set resource limits for Pro tier users. Typically left empty (unlimited).'
}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`${tier}-properties`}>Properties Limit</Label>
<div className="relative">
<Input
id={`${tier}-properties`}
type="text"
placeholder="Unlimited"
value={formData.properties_limit}
onChange={(e) => handleChange('properties_limit', e.target.value)}
/>
{formData.properties_limit === '' && (
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${tier}-tasks`}>Tasks Limit</Label>
<div className="relative">
<Input
id={`${tier}-tasks`}
type="text"
placeholder="Unlimited"
value={formData.tasks_limit}
onChange={(e) => handleChange('tasks_limit', e.target.value)}
/>
{formData.tasks_limit === '' && (
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${tier}-contractors`}>Contractors Limit</Label>
<div className="relative">
<Input
id={`${tier}-contractors`}
type="text"
placeholder="Unlimited"
value={formData.contractors_limit}
onChange={(e) => handleChange('contractors_limit', e.target.value)}
/>
{formData.contractors_limit === '' && (
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${tier}-documents`}>Documents Limit</Label>
<div className="relative">
<Input
id={`${tier}-documents`}
type="text"
placeholder="Unlimited"
value={formData.documents_limit}
onChange={(e) => handleChange('documents_limit', e.target.value)}
/>
{formData.documents_limit === '' && (
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</div>
<Button
onClick={() => onSave(tier, formData)}
className="w-full"
>
Save {tier.charAt(0).toUpperCase() + tier.slice(1)} Tier Limits
</Button>
</CardContent>
</Card>
);
}
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 (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Subscription Limitations</h1>
<p className="text-muted-foreground">
Configure tier-based resource limits for users
</p>
</div>
{/* Enable Limitations Toggle */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Enable Limitations
</CardTitle>
<CardDescription>
Control whether tier-based limitations are enforced for users. When disabled, all users have full access regardless of their subscription tier.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="enable-limitations">Enforce Tier Limitations</Label>
<p className="text-sm text-muted-foreground">
{settings?.enable_limitations
? 'Limitations are currently ENABLED. Free tier users have restricted access.'
: 'Limitations are currently DISABLED. All users have full access.'
}
</p>
</div>
<Switch
id="enable-limitations"
checked={settings?.enable_limitations ?? false}
onCheckedChange={handleLimitationsToggle}
disabled={updateSettingsMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Tier Limits */}
<div className="grid gap-6 md:grid-cols-2">
<TierLimitsCard
tier="free"
limits={freeLimits}
onSave={handleSaveTierLimits}
/>
<TierLimitsCard
tier="pro"
limits={proLimits}
onSave={handleSaveTierLimits}
/>
</div>
{/* Info Box */}
<Card className="bg-muted/50">
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>How limits work:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Empty fields mean unlimited access for that resource</li>
<li>A value of 0 means no access (blocked)</li>
<li>Limits only apply when "Enable Limitations" is turned ON</li>
<li>Pro tier users typically have unlimited access</li>
<li>Changes take effect immediately for all users</li>
</ul>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<TriggerFormData>(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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{trigger ? 'Edit' : 'Create'} Upgrade Trigger</DialogTitle>
<DialogDescription>
Configure the upgrade prompt that appears when users hit this trigger point.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="trigger_key">Trigger Point *</Label>
<Select
value={formData.trigger_key}
onValueChange={(value) => setFormData(prev => ({ ...prev, trigger_key: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select trigger point" />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.key} value={key.key}>
{key.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g., Unlock More Properties"
/>
</div>
<div className="space-y-2">
<Label htmlFor="message">Message *</Label>
<Textarea
id="message"
value={formData.message}
onChange={(e) => setFormData(prev => ({ ...prev, message: e.target.value }))}
placeholder="e.g., Upgrade to Pro to add unlimited properties..."
rows={3}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="promo_html">Promo HTML (Optional)</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? <EyeOff className="h-4 w-4 mr-1" /> : <Eye className="h-4 w-4 mr-1" />}
{showPreview ? 'Hide Preview' : 'Show Preview'}
</Button>
</div>
<Textarea
id="promo_html"
value={formData.promo_html}
onChange={(e) => setFormData(prev => ({ ...prev, promo_html: e.target.value }))}
placeholder="<div>Rich HTML content for the upgrade prompt...</div>"
rows={5}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Use HTML to create rich promotional content. Supports basic HTML tags.
</p>
{showPreview && formData.promo_html && (
<Card className="mt-2">
<CardHeader className="py-2">
<CardTitle className="text-sm flex items-center gap-2">
<Code className="h-4 w-4" />
HTML Preview
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: formData.promo_html }}
/>
</CardContent>
</Card>
)}
</div>
<div className="space-y-2">
<Label htmlFor="button_text">Button Text</Label>
<Input
id="button_text"
value={formData.button_text}
onChange={(e) => setFormData(prev => ({ ...prev, button_text: e.target.value }))}
placeholder="Upgrade to Pro"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active">Active</Label>
<p className="text-xs text-muted-foreground">
Only active triggers will be shown to users
</p>
</div>
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, is_active: checked }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>
{trigger ? 'Save Changes' : 'Create Trigger'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function UpgradeTriggersPage() {
const queryClient = useQueryClient();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingTrigger, setEditingTrigger] = useState<UpgradeTrigger | null>(null);
const { data: triggers, isLoading: triggersLoading } = useQuery({
queryKey: ['upgrade-triggers'],
queryFn: limitationsApi.listUpgradeTriggers,
});
const { data: triggerKeys, isLoading: keysLoading } = useQuery({
queryKey: ['trigger-keys'],
queryFn: limitationsApi.getAvailableTriggerKeys,
});
const createMutation = useMutation({
mutationFn: limitationsApi.createUpgradeTrigger,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
toast.success('Upgrade trigger created');
setIsCreateOpen(false);
},
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
const message = error.response?.data?.error || error.message || 'Failed to create upgrade trigger';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateUpgradeTriggerRequest }) =>
limitationsApi.updateUpgradeTrigger(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
toast.success('Upgrade trigger updated');
setEditingTrigger(null);
},
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
const message = error.response?.data?.error || error.message || 'Failed to update upgrade trigger';
toast.error(message);
},
});
const deleteMutation = useMutation({
mutationFn: limitationsApi.deleteUpgradeTrigger,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
toast.success('Upgrade trigger deleted');
},
onError: () => {
toast.error('Failed to delete upgrade trigger');
},
});
const toggleActiveMutation = useMutation({
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
limitationsApi.updateUpgradeTrigger(id, { is_active }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
toast.success('Trigger status updated');
},
onError: () => {
toast.error('Failed to update trigger status');
},
});
const handleSave = (data: CreateUpgradeTriggerRequest | UpdateUpgradeTriggerRequest, id?: number) => {
if (id) {
updateMutation.mutate({ id, data });
} else {
createMutation.mutate(data as CreateUpgradeTriggerRequest);
}
};
const existingTriggerKeys = triggers?.map(t => t.trigger_key) ?? [];
const getTriggerLabel = (key: string): string => {
return triggerKeys?.find(k => k.key === key)?.label ?? key;
};
if (triggersLoading || keysLoading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Upgrade Triggers</h1>
<p className="text-muted-foreground">
Configure upgrade prompts shown to free tier users at specific trigger points
</p>
</div>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Trigger
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Configured Triggers
</CardTitle>
<CardDescription>
These prompts appear when free tier users try to access Pro features
</CardDescription>
</CardHeader>
<CardContent>
{triggers && triggers.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Trigger Point</TableHead>
<TableHead>Title</TableHead>
<TableHead>Has HTML</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{triggers.map((trigger) => (
<TableRow key={trigger.id}>
<TableCell className="font-medium">
{getTriggerLabel(trigger.trigger_key)}
</TableCell>
<TableCell>{trigger.title}</TableCell>
<TableCell>
{trigger.promo_html ? (
<Badge variant="secondary">
<Code className="h-3 w-3 mr-1" />
Yes
</Badge>
) : (
<span className="text-muted-foreground">No</span>
)}
</TableCell>
<TableCell>
<Switch
checked={trigger.is_active}
onCheckedChange={(checked) =>
toggleActiveMutation.mutate({ id: trigger.id, is_active: checked })
}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingTrigger(trigger)}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Trigger?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the &quot;{trigger.title}&quot; upgrade trigger.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(trigger.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No upgrade triggers configured yet.</p>
<p className="text-sm">Click &quot;Add Trigger&quot; to create your first one.</p>
</div>
)}
</CardContent>
</Card>
{/* Info Box */}
<Card className="bg-muted/50">
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>Available Trigger Points:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
{triggerKeys?.map((key) => (
<li key={key.key}>
<strong>{key.label}</strong> ({key.key})
</li>
))}
</ul>
</div>
</CardContent>
</Card>
{/* Create Dialog */}
{isCreateOpen && triggerKeys && triggerKeys.length > 0 && (
<TriggerFormDialog
key="create-new"
triggerKeys={triggerKeys}
existingTriggerKeys={existingTriggerKeys}
onSave={handleSave}
onClose={() => setIsCreateOpen(false)}
isOpen={isCreateOpen}
/>
)}
{/* Edit Dialog */}
{editingTrigger && triggerKeys && triggerKeys.length > 0 && (
<TriggerFormDialog
key={editingTrigger.id}
trigger={editingTrigger}
triggerKeys={triggerKeys}
existingTriggerKeys={existingTriggerKeys}
onSave={handleSave}
onClose={() => setEditingTrigger(null)}
isOpen={!!editingTrigger}
/>
)}
</div>
);
}

View File

@@ -1,12 +1,10 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Database, TestTube, Shield } from 'lucide-react'; import { Database, TestTube } from 'lucide-react';
import { settingsApi } from '@/lib/api'; import { settingsApi } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { import {
Card, Card,
CardContent, CardContent,
@@ -28,24 +26,6 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: settingsApi.get,
});
const updateMutation = useMutation({
mutationFn: settingsApi.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
toast.success('Settings updated');
},
onError: () => {
toast.error('Failed to update settings');
},
});
const seedLookupsMutation = useMutation({ const seedLookupsMutation = useMutation({
mutationFn: settingsApi.seedLookups, mutationFn: settingsApi.seedLookups,
onSuccess: (data) => { onSuccess: (data) => {
@@ -66,26 +46,6 @@ export default function SettingsPage() {
}, },
}); });
const handleLimitationsToggle = () => {
if (settings) {
updateMutation.mutate({
enable_limitations: !settings.enable_limitations,
});
}
};
if (isLoading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div>
@@ -96,35 +56,6 @@ export default function SettingsPage() {
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Subscription Limitations */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Subscription Limitations
</CardTitle>
<CardDescription>
Control whether tier-based limitations are enforced for users
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="enable-limitations">Enable Limitations</Label>
<p className="text-sm text-muted-foreground">
When enabled, free tier users will have restricted access to features
</p>
</div>
<Switch
id="enable-limitations"
checked={settings?.enable_limitations ?? false}
onCheckedChange={handleLimitationsToggle}
disabled={updateMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Seed Lookup Data */} {/* Seed Lookup Data */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -16,6 +16,9 @@ import {
CheckCircle, CheckCircle,
BookOpen, BookOpen,
UserCog, UserCog,
Shield,
Layers,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
@@ -47,6 +50,11 @@ const menuItems = [
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard }, { title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
]; ];
const limitationsItems = [
{ title: 'Tier Limits', url: '/admin/limitations', icon: Layers },
{ title: 'Upgrade Triggers', url: '/admin/limitations/triggers', icon: Sparkles },
];
const settingsItems = [ const settingsItems = [
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen }, { title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog }, { title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
@@ -94,6 +102,27 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Limitations</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{limitationsItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={pathname === item.url || (item.url !== '/admin/limitations' && pathname.startsWith(item.url))}
>
<a href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>System</SidebarGroupLabel> <SidebarGroupLabel>System</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>

View File

@@ -392,6 +392,13 @@ export const authTokensApi = {
}, },
}; };
// Task Completion Image
export interface TaskCompletionImage {
id: number;
image_url: string;
caption: string;
}
// Task Completions Types // Task Completions Types
export interface TaskCompletion { export interface TaskCompletion {
id: number; id: number;
@@ -404,7 +411,8 @@ export interface TaskCompletion {
completed_at: string; completed_at: string;
notes: string; notes: string;
actual_cost: string | null; actual_cost: string | null;
photo_url: string; rating: number | null;
images: TaskCompletionImage[];
created_at: string; created_at: string;
} }
@@ -664,4 +672,122 @@ export const settingsApi = {
}, },
}; };
// Limitations types
export interface LimitationsSettings {
enable_limitations: boolean;
}
export interface TierLimits {
id: number;
tier: 'free' | 'pro';
properties_limit: number | null;
tasks_limit: number | null;
contractors_limit: number | null;
documents_limit: number | null;
created_at: string;
updated_at: string;
}
export interface UpdateTierLimitsRequest {
properties_limit: number | null;
tasks_limit: number | null;
contractors_limit: number | null;
documents_limit: number | null;
}
export interface UpgradeTrigger {
id: number;
trigger_key: string;
title: string;
message: string;
promo_html: string;
button_text: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface TriggerKeyOption {
key: string;
label: string;
}
export interface CreateUpgradeTriggerRequest {
trigger_key: string;
title: string;
message: string;
promo_html?: string;
button_text?: string;
is_active?: boolean;
}
export interface UpdateUpgradeTriggerRequest {
trigger_key?: string;
title?: string;
message?: string;
promo_html?: string;
button_text?: string;
is_active?: boolean;
}
// Limitations API
export const limitationsApi = {
// Settings
getSettings: async (): Promise<LimitationsSettings> => {
const response = await api.get<LimitationsSettings>('/limitations/settings');
return response.data;
},
updateSettings: async (data: { enable_limitations: boolean }): Promise<LimitationsSettings> => {
const response = await api.put<LimitationsSettings>('/limitations/settings', data);
return response.data;
},
// Tier Limits
listTierLimits: async (): Promise<TierLimits[]> => {
const response = await api.get<{ data: TierLimits[]; total: number }>('/limitations/tier-limits');
return response.data.data;
},
getTierLimits: async (tier: 'free' | 'pro'): Promise<TierLimits> => {
const response = await api.get<TierLimits>(`/limitations/tier-limits/${tier}`);
return response.data;
},
updateTierLimits: async (tier: 'free' | 'pro', data: UpdateTierLimitsRequest): Promise<TierLimits> => {
const response = await api.put<TierLimits>(`/limitations/tier-limits/${tier}`, data);
return response.data;
},
// Upgrade Triggers
getAvailableTriggerKeys: async (): Promise<TriggerKeyOption[]> => {
const response = await api.get<TriggerKeyOption[]>('/limitations/upgrade-triggers/keys');
return response.data;
},
listUpgradeTriggers: async (): Promise<UpgradeTrigger[]> => {
const response = await api.get<{ data: UpgradeTrigger[]; total: number }>('/limitations/upgrade-triggers');
return response.data.data;
},
getUpgradeTrigger: async (id: number): Promise<UpgradeTrigger> => {
const response = await api.get<UpgradeTrigger>(`/limitations/upgrade-triggers/${id}`);
return response.data;
},
createUpgradeTrigger: async (data: CreateUpgradeTriggerRequest): Promise<UpgradeTrigger> => {
const response = await api.post<UpgradeTrigger>('/limitations/upgrade-triggers', data);
return response.data;
},
updateUpgradeTrigger: async (id: number, data: UpdateUpgradeTriggerRequest): Promise<UpgradeTrigger> => {
const response = await api.put<UpgradeTrigger>(`/limitations/upgrade-triggers/${id}`, data);
return response.data;
},
deleteUpgradeTrigger: async (id: number): Promise<void> => {
await api.delete(`/limitations/upgrade-triggers/${id}`);
},
};
export default api; export default api;

View File

@@ -265,6 +265,12 @@ export interface UpdateContractorRequest {
} }
// Document types // Document types
export interface DocumentImage {
id: number;
image_url: string;
caption: string;
}
export interface Document { export interface Document {
id: number; id: number;
residence_id: number; residence_id: number;
@@ -278,6 +284,7 @@ export interface Document {
expiry_date?: string; expiry_date?: string;
purchase_date?: string; purchase_date?: string;
is_active: boolean; is_active: boolean;
images: DocumentImage[];
created_at: string; created_at: string;
} }
@@ -308,6 +315,7 @@ export interface CreateDocumentRequest {
serial_number?: string; serial_number?: string;
model_number?: string; model_number?: string;
task_id?: number; task_id?: number;
image_urls?: string[];
} }
export interface UpdateDocumentRequest { export interface UpdateDocumentRequest {

View File

@@ -152,21 +152,29 @@ type ContractorDetailResponse struct {
TaskCount int `json:"task_count"` TaskCount int `json:"task_count"`
} }
// DocumentImageResponse represents a document image
type DocumentImageResponse struct {
ID uint `json:"id"`
ImageURL string `json:"image_url"`
Caption string `json:"caption"`
}
// DocumentResponse represents a document in admin responses // DocumentResponse represents a document in admin responses
type DocumentResponse struct { type DocumentResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
ResidenceID uint `json:"residence_id"` ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"` ResidenceName string `json:"residence_name"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
DocumentType string `json:"document_type"` DocumentType string `json:"document_type"`
FileName string `json:"file_name"` FileName string `json:"file_name"`
FileURL string `json:"file_url"` FileURL string `json:"file_url"`
Vendor string `json:"vendor"` Vendor string `json:"vendor"`
ExpiryDate *string `json:"expiry_date,omitempty"` ExpiryDate *string `json:"expiry_date,omitempty"`
PurchaseDate *string `json:"purchase_date,omitempty"` PurchaseDate *string `json:"purchase_date,omitempty"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"` Images []DocumentImageResponse `json:"images"`
CreatedAt string `json:"created_at"`
} }
// DocumentDetailResponse includes more details for single document view // DocumentDetailResponse includes more details for single document view

View File

@@ -22,20 +22,28 @@ func NewAdminCompletionHandler(db *gorm.DB) *AdminCompletionHandler {
return &AdminCompletionHandler{db: db} return &AdminCompletionHandler{db: db}
} }
// CompletionImageResponse represents an image in a completion
type CompletionImageResponse struct {
ID uint `json:"id"`
ImageURL string `json:"image_url"`
Caption string `json:"caption"`
}
// CompletionResponse represents a task completion in API responses // CompletionResponse represents a task completion in API responses
type CompletionResponse struct { type CompletionResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
TaskID uint `json:"task_id"` TaskID uint `json:"task_id"`
TaskTitle string `json:"task_title"` TaskTitle string `json:"task_title"`
ResidenceID uint `json:"residence_id"` ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"` ResidenceName string `json:"residence_name"`
CompletedByID uint `json:"completed_by_id"` CompletedByID uint `json:"completed_by_id"`
CompletedBy string `json:"completed_by"` CompletedBy string `json:"completed_by"`
CompletedAt string `json:"completed_at"` CompletedAt string `json:"completed_at"`
Notes string `json:"notes"` Notes string `json:"notes"`
ActualCost *string `json:"actual_cost"` ActualCost *string `json:"actual_cost"`
PhotoURL string `json:"photo_url"` Rating *int `json:"rating"`
CreatedAt string `json:"created_at"` Images []CompletionImageResponse `json:"images"`
CreatedAt string `json:"created_at"`
} }
// CompletionFilters extends PaginationParams with completion-specific filters // CompletionFilters extends PaginationParams with completion-specific filters
@@ -60,7 +68,8 @@ func (h *AdminCompletionHandler) List(c *gin.Context) {
query := h.db.Model(&models.TaskCompletion{}). query := h.db.Model(&models.TaskCompletion{}).
Preload("Task"). Preload("Task").
Preload("Task.Residence"). Preload("Task.Residence").
Preload("CompletedBy") Preload("CompletedBy").
Preload("Images")
// Apply search // Apply search
if filters.Search != "" { if filters.Search != "" {
@@ -125,7 +134,7 @@ func (h *AdminCompletionHandler) Get(c *gin.Context) {
} }
var completion models.TaskCompletion var completion models.TaskCompletion
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id).Error; err != nil { if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return return
@@ -229,7 +238,7 @@ func (h *AdminCompletionHandler) Update(c *gin.Context) {
return return
} }
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id) h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id)
c.JSON(http.StatusOK, h.toCompletionResponse(&completion)) c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
} }
@@ -241,7 +250,8 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
CompletedByID: completion.CompletedByID, CompletedByID: completion.CompletedByID,
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"), CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
Notes: completion.Notes, Notes: completion.Notes,
PhotoURL: completion.PhotoURL, Rating: completion.Rating,
Images: make([]CompletionImageResponse, 0),
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"), CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
} }
@@ -262,5 +272,14 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
response.ActualCost = &cost response.ActualCost = &cost
} }
// Convert images
for _, img := range completion.Images {
response.Images = append(response.Images, CompletionImageResponse{
ID: img.ID,
ImageURL: img.ImageURL,
Caption: img.Caption,
})
}
return response return response
} }

View File

@@ -36,7 +36,8 @@ func (h *AdminDocumentHandler) List(c *gin.Context) {
query := h.db.Model(&models.Document{}). query := h.db.Model(&models.Document{}).
Preload("Residence"). Preload("Residence").
Preload("CreatedBy") Preload("CreatedBy").
Preload("Images")
// Apply search // Apply search
if filters.Search != "" { if filters.Search != "" {
@@ -98,6 +99,7 @@ func (h *AdminDocumentHandler) Get(c *gin.Context) {
Preload("Residence"). Preload("Residence").
Preload("CreatedBy"). Preload("CreatedBy").
Preload("Task"). Preload("Task").
Preload("Images").
First(&document, id).Error; err != nil { First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
@@ -166,7 +168,7 @@ func (h *AdminDocumentHandler) Update(c *gin.Context) {
return return
} }
h.db.Preload("Residence").Preload("CreatedBy").First(&document, id) h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, id)
c.JSON(http.StatusOK, h.toDocumentResponse(&document)) c.JSON(http.StatusOK, h.toDocumentResponse(&document))
} }
@@ -236,7 +238,7 @@ func (h *AdminDocumentHandler) Create(c *gin.Context) {
return return
} }
h.db.Preload("Residence").Preload("CreatedBy").First(&document, document.ID) h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, document.ID)
c.JSON(http.StatusCreated, h.toDocumentResponse(&document)) c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
} }
@@ -296,6 +298,7 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
FileURL: doc.FileURL, FileURL: doc.FileURL,
Vendor: doc.Vendor, Vendor: doc.Vendor,
IsActive: doc.IsActive, IsActive: doc.IsActive,
Images: make([]dto.DocumentImageResponse, 0),
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"), CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
} }
@@ -311,5 +314,14 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
response.PurchaseDate = &purchaseDate response.PurchaseDate = &purchaseDate
} }
// Convert images
for _, img := range doc.Images {
response.Images = append(response.Images, dto.DocumentImageResponse{
ID: img.ID,
ImageURL: img.ImageURL,
Caption: img.Caption,
})
}
return response return response
} }

View File

@@ -0,0 +1,480 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminLimitationsHandler handles subscription limitations management
type AdminLimitationsHandler struct {
db *gorm.DB
}
// NewAdminLimitationsHandler creates a new handler
func NewAdminLimitationsHandler(db *gorm.DB) *AdminLimitationsHandler {
return &AdminLimitationsHandler{db: db}
}
// === Settings (enable_limitations) ===
// LimitationsSettingsResponse represents the limitations settings
type LimitationsSettingsResponse struct {
EnableLimitations bool `json:"enable_limitations"`
}
// GetSettings handles GET /api/admin/limitations/settings
func (h *AdminLimitationsHandler) GetSettings(c *gin.Context) {
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create default settings
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
h.db.Create(&settings)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
}
c.JSON(http.StatusOK, LimitationsSettingsResponse{
EnableLimitations: settings.EnableLimitations,
})
}
// UpdateSettingsRequest represents the update request
type UpdateLimitationsSettingsRequest struct {
EnableLimitations *bool `json:"enable_limitations"`
}
// UpdateSettings handles PUT /api/admin/limitations/settings
func (h *AdminLimitationsHandler) UpdateSettings(c *gin.Context) {
var req UpdateLimitationsSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
settings = models.SubscriptionSettings{ID: 1}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
}
if req.EnableLimitations != nil {
settings.EnableLimitations = *req.EnableLimitations
}
if err := h.db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
c.JSON(http.StatusOK, LimitationsSettingsResponse{
EnableLimitations: settings.EnableLimitations,
})
}
// === Tier Limits ===
// TierLimitsResponse represents tier limits in API response
type TierLimitsResponse struct {
ID uint `json:"id"`
Tier string `json:"tier"`
PropertiesLimit *int `json:"properties_limit"`
TasksLimit *int `json:"tasks_limit"`
ContractorsLimit *int `json:"contractors_limit"`
DocumentsLimit *int `json:"documents_limit"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toTierLimitsResponse(t *models.TierLimits) TierLimitsResponse {
return TierLimitsResponse{
ID: t.ID,
Tier: string(t.Tier),
PropertiesLimit: t.PropertiesLimit,
TasksLimit: t.TasksLimit,
ContractorsLimit: t.ContractorsLimit,
DocumentsLimit: t.DocumentsLimit,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ListTierLimits handles GET /api/admin/limitations/tier-limits
func (h *AdminLimitationsHandler) ListTierLimits(c *gin.Context) {
var limits []models.TierLimits
if err := h.db.Order("tier").Find(&limits).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
return
}
// If no limits exist, create defaults
if len(limits) == 0 {
freeLimits := models.GetDefaultFreeLimits()
proLimits := models.GetDefaultProLimits()
h.db.Create(&freeLimits)
h.db.Create(&proLimits)
limits = []models.TierLimits{freeLimits, proLimits}
}
responses := make([]TierLimitsResponse, len(limits))
for i, l := range limits {
responses[i] = toTierLimitsResponse(&l)
}
c.JSON(http.StatusOK, gin.H{
"data": responses,
"total": len(responses),
})
}
// GetTierLimits handles GET /api/admin/limitations/tier-limits/:tier
func (h *AdminLimitationsHandler) GetTierLimits(c *gin.Context) {
tier := c.Param("tier")
if tier != "free" && tier != "pro" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tier. Must be 'free' or 'pro'"})
return
}
var limits models.TierLimits
if err := h.db.Where("tier = ?", tier).First(&limits).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create default
if tier == "free" {
limits = models.GetDefaultFreeLimits()
} else {
limits = models.GetDefaultProLimits()
}
h.db.Create(&limits)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
return
}
}
c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
}
// UpdateTierLimitsRequest represents the update request for tier limits
type UpdateTierLimitsRequest struct {
PropertiesLimit *int `json:"properties_limit"`
TasksLimit *int `json:"tasks_limit"`
ContractorsLimit *int `json:"contractors_limit"`
DocumentsLimit *int `json:"documents_limit"`
}
// UpdateTierLimits handles PUT /api/admin/limitations/tier-limits/:tier
func (h *AdminLimitationsHandler) UpdateTierLimits(c *gin.Context) {
tier := c.Param("tier")
if tier != "free" && tier != "pro" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tier. Must be 'free' or 'pro'"})
return
}
var req UpdateTierLimitsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var limits models.TierLimits
if err := h.db.Where("tier = ?", tier).First(&limits).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create new entry
limits = models.TierLimits{Tier: models.SubscriptionTier(tier)}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
return
}
}
// Update fields - note: we need to handle nil vs zero difference
// A nil pointer in the request means "don't change"
// The actual limit value can be nil (unlimited) or a number
limits.PropertiesLimit = req.PropertiesLimit
limits.TasksLimit = req.TasksLimit
limits.ContractorsLimit = req.ContractorsLimit
limits.DocumentsLimit = req.DocumentsLimit
if err := h.db.Save(&limits).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update tier limits"})
return
}
c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
}
// === Upgrade Triggers ===
// UpgradeTriggerResponse represents an upgrade trigger in API response
type UpgradeTriggerResponse struct {
ID uint `json:"id"`
TriggerKey string `json:"trigger_key"`
Title string `json:"title"`
Message string `json:"message"`
PromoHTML string `json:"promo_html"`
ButtonText string `json:"button_text"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toUpgradeTriggerResponse(t *models.UpgradeTrigger) UpgradeTriggerResponse {
return UpgradeTriggerResponse{
ID: t.ID,
TriggerKey: t.TriggerKey,
Title: t.Title,
Message: t.Message,
PromoHTML: t.PromoHTML,
ButtonText: t.ButtonText,
IsActive: t.IsActive,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// Available trigger keys
var availableTriggerKeys = []string{
"user_profile",
"add_second_property",
"add_11th_task",
"view_contractors",
"view_documents",
}
// GetAvailableTriggerKeys handles GET /api/admin/limitations/upgrade-triggers/keys
func (h *AdminLimitationsHandler) GetAvailableTriggerKeys(c *gin.Context) {
type KeyOption struct {
Key string `json:"key"`
Label string `json:"label"`
}
keys := []KeyOption{
{Key: "user_profile", Label: "User Profile"},
{Key: "add_second_property", Label: "Add Second Property"},
{Key: "add_11th_task", Label: "Add 11th Task"},
{Key: "view_contractors", Label: "View Contractors"},
{Key: "view_documents", Label: "View Documents & Warranties"},
}
c.JSON(http.StatusOK, keys)
}
// ListUpgradeTriggers handles GET /api/admin/limitations/upgrade-triggers
func (h *AdminLimitationsHandler) ListUpgradeTriggers(c *gin.Context) {
var triggers []models.UpgradeTrigger
if err := h.db.Order("trigger_key").Find(&triggers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade triggers"})
return
}
responses := make([]UpgradeTriggerResponse, len(triggers))
for i, t := range triggers {
responses[i] = toUpgradeTriggerResponse(&t)
}
c.JSON(http.StatusOK, gin.H{
"data": responses,
"total": len(responses),
})
}
// GetUpgradeTrigger handles GET /api/admin/limitations/upgrade-triggers/:id
func (h *AdminLimitationsHandler) GetUpgradeTrigger(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var trigger models.UpgradeTrigger
if err := h.db.First(&trigger, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
return
}
c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
}
// CreateUpgradeTriggerRequest represents the create request
type CreateUpgradeTriggerRequest struct {
TriggerKey string `json:"trigger_key" binding:"required"`
Title string `json:"title" binding:"required"`
Message string `json:"message" binding:"required"`
PromoHTML string `json:"promo_html"`
ButtonText string `json:"button_text"`
IsActive *bool `json:"is_active"`
}
// CreateUpgradeTrigger handles POST /api/admin/limitations/upgrade-triggers
func (h *AdminLimitationsHandler) CreateUpgradeTrigger(c *gin.Context) {
var req CreateUpgradeTriggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate trigger key
validKey := false
for _, k := range availableTriggerKeys {
if k == req.TriggerKey {
validKey = true
break
}
}
if !validKey {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid trigger_key"})
return
}
// Check if trigger key already exists
var existing models.UpgradeTrigger
if err := h.db.Where("trigger_key = ?", req.TriggerKey).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trigger key already exists"})
return
}
trigger := models.UpgradeTrigger{
TriggerKey: req.TriggerKey,
Title: req.Title,
Message: req.Message,
PromoHTML: req.PromoHTML,
ButtonText: req.ButtonText,
IsActive: true,
}
if req.ButtonText == "" {
trigger.ButtonText = "Upgrade to Pro"
}
if req.IsActive != nil {
trigger.IsActive = *req.IsActive
}
if err := h.db.Create(&trigger).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upgrade trigger"})
return
}
c.JSON(http.StatusCreated, toUpgradeTriggerResponse(&trigger))
}
// UpdateUpgradeTriggerRequest represents the update request
type UpdateUpgradeTriggerRequest struct {
TriggerKey *string `json:"trigger_key"`
Title *string `json:"title"`
Message *string `json:"message"`
PromoHTML *string `json:"promo_html"`
ButtonText *string `json:"button_text"`
IsActive *bool `json:"is_active"`
}
// UpdateUpgradeTrigger handles PUT /api/admin/limitations/upgrade-triggers/:id
func (h *AdminLimitationsHandler) UpdateUpgradeTrigger(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var trigger models.UpgradeTrigger
if err := h.db.First(&trigger, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
return
}
var req UpdateUpgradeTriggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.TriggerKey != nil {
// Validate new trigger key
validKey := false
for _, k := range availableTriggerKeys {
if k == *req.TriggerKey {
validKey = true
break
}
}
if !validKey {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid trigger_key"})
return
}
// Check if key is already used by another trigger
if *req.TriggerKey != trigger.TriggerKey {
var existing models.UpgradeTrigger
if err := h.db.Where("trigger_key = ?", *req.TriggerKey).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trigger key already exists"})
return
}
}
trigger.TriggerKey = *req.TriggerKey
}
if req.Title != nil {
trigger.Title = *req.Title
}
if req.Message != nil {
trigger.Message = *req.Message
}
if req.PromoHTML != nil {
trigger.PromoHTML = *req.PromoHTML
}
if req.ButtonText != nil {
trigger.ButtonText = *req.ButtonText
}
if req.IsActive != nil {
trigger.IsActive = *req.IsActive
}
if err := h.db.Save(&trigger).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update upgrade trigger"})
return
}
c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
}
// DeleteUpgradeTrigger handles DELETE /api/admin/limitations/upgrade-triggers/:id
func (h *AdminLimitationsHandler) DeleteUpgradeTrigger(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var trigger models.UpgradeTrigger
if err := h.db.First(&trigger, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
return
}
if err := h.db.Delete(&trigger).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete upgrade trigger"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Upgrade trigger deleted"})
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -125,17 +126,29 @@ func (h *AdminSettingsHandler) runSeedFile(filename string) error {
return err return err
} }
// Get the underlying *sql.DB to execute raw SQL without prepared statements
sqlDB, err := h.db.DB()
if err != nil {
return err
}
// Split SQL into individual statements and execute each one // Split SQL into individual statements and execute each one
// This is needed because GORM/PostgreSQL prepared statements don't support multiple commands // This is needed because GORM/PostgreSQL prepared statements don't support multiple commands
statements := splitSQLStatements(string(sqlContent)) statements := splitSQLStatements(string(sqlContent))
for _, stmt := range statements { for i, stmt := range statements {
stmt = strings.TrimSpace(stmt) stmt = strings.TrimSpace(stmt)
if stmt == "" { if stmt == "" {
continue continue
} }
if err := h.db.Exec(stmt).Error; err != nil { // Use the raw sql.DB to avoid GORM's prepared statement handling
return err if _, err := sqlDB.Exec(stmt); err != nil {
// Include statement number and first 100 chars for debugging
preview := stmt
if len(preview) > 100 {
preview = preview[:100] + "..."
}
return fmt.Errorf("statement %d failed: %v\nStatement: %s", i+1, err, preview)
} }
} }

View File

@@ -259,6 +259,28 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
settings.POST("/seed-lookups", settingsHandler.SeedLookups) settings.POST("/seed-lookups", settingsHandler.SeedLookups)
settings.POST("/seed-test-data", settingsHandler.SeedTestData) settings.POST("/seed-test-data", settingsHandler.SeedTestData)
} }
// Limitations management (tier limits, upgrade triggers)
limitationsHandler := handlers.NewAdminLimitationsHandler(db)
limitations := protected.Group("/limitations")
{
// Settings (enable_limitations toggle)
limitations.GET("/settings", limitationsHandler.GetSettings)
limitations.PUT("/settings", limitationsHandler.UpdateSettings)
// Tier Limits
limitations.GET("/tier-limits", limitationsHandler.ListTierLimits)
limitations.GET("/tier-limits/:tier", limitationsHandler.GetTierLimits)
limitations.PUT("/tier-limits/:tier", limitationsHandler.UpdateTierLimits)
// Upgrade Triggers
limitations.GET("/upgrade-triggers/keys", limitationsHandler.GetAvailableTriggerKeys)
limitations.GET("/upgrade-triggers", limitationsHandler.ListUpgradeTriggers)
limitations.POST("/upgrade-triggers", limitationsHandler.CreateUpgradeTrigger)
limitations.GET("/upgrade-triggers/:id", limitationsHandler.GetUpgradeTrigger)
limitations.PUT("/upgrade-triggers/:id", limitationsHandler.UpdateUpgradeTrigger)
limitations.DELETE("/upgrade-triggers/:id", limitationsHandler.DeleteUpgradeTrigger)
}
} }
} }

View File

@@ -133,7 +133,9 @@ func Migrate() error {
&models.Contractor{}, // Contractor before Task (Task references Contractor) &models.Contractor{}, // Contractor before Task (Task references Contractor)
&models.Task{}, &models.Task{},
&models.TaskCompletion{}, &models.TaskCompletion{},
&models.TaskCompletionImage{}, // Multiple images per completion
&models.Document{}, &models.Document{},
&models.DocumentImage{}, // Multiple images per document
// Notification tables // Notification tables
&models.Notification{}, &models.Notification{},

View File

@@ -25,6 +25,7 @@ type CreateDocumentRequest struct {
SerialNumber string `json:"serial_number" binding:"max=100"` SerialNumber string `json:"serial_number" binding:"max=100"`
ModelNumber string `json:"model_number" binding:"max=100"` ModelNumber string `json:"model_number" binding:"max=100"`
TaskID *uint `json:"task_id"` TaskID *uint `json:"task_id"`
ImageURLs []string `json:"image_urls"` // Multiple image URLs
} }
// UpdateDocumentRequest represents the request to update a document // UpdateDocumentRequest represents the request to update a document

View File

@@ -88,5 +88,12 @@ type CreateTaskCompletionRequest struct {
CompletedAt *time.Time `json:"completed_at"` // Defaults to now CompletedAt *time.Time `json:"completed_at"` // Defaults to now
Notes string `json:"notes"` Notes string `json:"notes"`
ActualCost *decimal.Decimal `json:"actual_cost"` ActualCost *decimal.Decimal `json:"actual_cost"`
PhotoURL string `json:"photo_url"` Rating *int `json:"rating"` // 1-5 star rating
ImageURLs []string `json:"image_urls"` // Multiple image URLs
}
// CompletionImageInput represents an image to add to a completion
type CompletionImageInput struct {
ImageURL string `json:"image_url" binding:"required"`
Caption string `json:"caption"`
} }

View File

@@ -16,6 +16,13 @@ type DocumentUserResponse struct {
LastName string `json:"last_name"` LastName string `json:"last_name"`
} }
// DocumentImageResponse represents an image in a document
type DocumentImageResponse struct {
ID uint `json:"id"`
ImageURL string `json:"image_url"`
Caption string `json:"caption"`
}
// DocumentResponse represents a document in the API response // DocumentResponse represents a document in the API response
type DocumentResponse struct { type DocumentResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
@@ -36,10 +43,11 @@ type DocumentResponse struct {
Vendor string `json:"vendor"` Vendor string `json:"vendor"`
SerialNumber string `json:"serial_number"` SerialNumber string `json:"serial_number"`
ModelNumber string `json:"model_number"` ModelNumber string `json:"model_number"`
TaskID *uint `json:"task_id"` TaskID *uint `json:"task_id"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` Images []DocumentImageResponse `json:"images"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// Note: Pagination removed - list endpoints now return arrays directly // Note: Pagination removed - list endpoints now return arrays directly
@@ -81,6 +89,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
ModelNumber: d.ModelNumber, ModelNumber: d.ModelNumber,
TaskID: d.TaskID, TaskID: d.TaskID,
IsActive: d.IsActive, IsActive: d.IsActive,
Images: make([]DocumentImageResponse, 0),
CreatedAt: d.CreatedAt, CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt, UpdatedAt: d.UpdatedAt,
} }
@@ -89,6 +98,15 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy) resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy)
} }
// Convert images
for _, img := range d.Images {
resp.Images = append(resp.Images, DocumentImageResponse{
ID: img.ID,
ImageURL: img.ImageURL,
Caption: img.Caption,
})
}
return resp return resp
} }

View File

@@ -54,16 +54,24 @@ type TaskUserResponse struct {
LastName string `json:"last_name"` LastName string `json:"last_name"`
} }
// TaskCompletionImageResponse represents a completion image
type TaskCompletionImageResponse struct {
ID uint `json:"id"`
ImageURL string `json:"image_url"`
Caption string `json:"caption"`
}
// TaskCompletionResponse represents a task completion // TaskCompletionResponse represents a task completion
type TaskCompletionResponse struct { type TaskCompletionResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
TaskID uint `json:"task_id"` TaskID uint `json:"task_id"`
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"` CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
CompletedAt time.Time `json:"completed_at"` CompletedAt time.Time `json:"completed_at"`
Notes string `json:"notes"` Notes string `json:"notes"`
ActualCost *decimal.Decimal `json:"actual_cost"` ActualCost *decimal.Decimal `json:"actual_cost"`
PhotoURL string `json:"photo_url"` Rating *int `json:"rating"`
CreatedAt time.Time `json:"created_at"` Images []TaskCompletionImageResponse `json:"images"`
CreatedAt time.Time `json:"created_at"`
} }
// TaskResponse represents a task in the API response // TaskResponse represents a task in the API response
@@ -198,12 +206,21 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
CompletedAt: c.CompletedAt, CompletedAt: c.CompletedAt,
Notes: c.Notes, Notes: c.Notes,
ActualCost: c.ActualCost, ActualCost: c.ActualCost,
PhotoURL: c.PhotoURL, Rating: c.Rating,
Images: make([]TaskCompletionImageResponse, 0),
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
} }
if c.CompletedBy.ID != 0 { if c.CompletedBy.ID != 0 {
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy) resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
} }
// Convert images
for _, img := range c.Images {
resp.Images = append(resp.Images, TaskCompletionImageResponse{
ID: img.ID,
ImageURL: img.ImageURL,
Caption: img.Caption,
})
}
return resp return resp
} }

View File

@@ -64,6 +64,17 @@ func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) {
c.JSON(http.StatusOK, trigger) c.JSON(http.StatusOK, trigger)
} }
// GetAllUpgradeTriggers handles GET /api/subscription/upgrade-triggers/
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c *gin.Context) {
triggers, err := h.subscriptionService.GetAllUpgradeTriggers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, triggers)
}
// GetFeatureBenefits handles GET /api/subscription/features/ // GetFeatureBenefits handles GET /api/subscription/features/
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) { func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
benefits, err := h.subscriptionService.GetFeatureBenefits() benefits, err := h.subscriptionService.GetFeatureBenefits()

View File

@@ -419,7 +419,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()})
return return
} }
req.PhotoURL = result.URL req.ImageURLs = append(req.ImageURLs, result.URL)
} }
} }
} else { } else {

View File

@@ -37,12 +37,20 @@ type Document struct {
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"` MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
// Warranty-specific fields // Warranty-specific fields
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"` PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"` ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"` PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
Vendor string `gorm:"column:vendor;size:200" json:"vendor"` Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"` SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"` ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
// Warranty provider contact fields
Provider string `gorm:"column:provider;size:200" json:"provider"`
ProviderContact string `gorm:"column:provider_contact;size:200" json:"provider_contact"`
ClaimPhone string `gorm:"column:claim_phone;size:50" json:"claim_phone"`
ClaimEmail string `gorm:"column:claim_email;size:200" json:"claim_email"`
ClaimWebsite string `gorm:"column:claim_website;size:500" json:"claim_website"`
Notes string `gorm:"column:notes;type:text" json:"notes"`
// Associated task (optional) // Associated task (optional)
TaskID *uint `gorm:"column:task_id;index" json:"task_id"` TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
@@ -50,6 +58,9 @@ type Document struct {
// State // State
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"` IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
// Multiple images support
Images []DocumentImage `gorm:"foreignKey:DocumentID" json:"images,omitempty"`
} }
// TableName returns the table name for GORM // TableName returns the table name for GORM
@@ -73,3 +84,16 @@ func (d *Document) IsWarrantyExpired() bool {
} }
return time.Now().UTC().After(*d.ExpiryDate) return time.Now().UTC().After(*d.ExpiryDate)
} }
// DocumentImage represents the task_documentimage table
type DocumentImage struct {
BaseModel
DocumentID uint `gorm:"column:document_id;index;not null" json:"document_id"`
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
Caption string `gorm:"column:caption;size:255" json:"caption"`
}
// TableName returns the table name for GORM
func (DocumentImage) TableName() string {
return "task_documentimage"
}

View File

@@ -143,7 +143,10 @@ type TaskCompletion struct {
CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"` CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"`
Notes string `gorm:"column:notes;type:text" json:"notes"` Notes string `gorm:"column:notes;type:text" json:"notes"`
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"` ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
PhotoURL string `gorm:"column:photo_url;size:500" json:"photo_url"` Rating *int `gorm:"column:rating" json:"rating"` // 1-5 star rating
// Multiple images support
Images []TaskCompletionImage `gorm:"foreignKey:CompletionID" json:"images,omitempty"`
} }
// TableName returns the table name for GORM // TableName returns the table name for GORM
@@ -151,6 +154,19 @@ func (TaskCompletion) TableName() string {
return "task_taskcompletion" return "task_taskcompletion"
} }
// TaskCompletionImage represents the task_taskcompletionimage table
type TaskCompletionImage struct {
BaseModel
CompletionID uint `gorm:"column:completion_id;index;not null" json:"completion_id"`
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
Caption string `gorm:"column:caption;size:255" json:"caption"`
}
// TableName returns the table name for GORM
func (TaskCompletionImage) TableName() string {
return "task_taskcompletionimage"
}
// KanbanColumn represents a column in the kanban board // KanbanColumn represents a column in the kanban board
type KanbanColumn struct { type KanbanColumn struct {
Name string `json:"name"` Name string `json:"name"`

View File

@@ -23,6 +23,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
var document models.Document var document models.Document
err := r.db.Preload("CreatedBy"). err := r.db.Preload("CreatedBy").
Preload("Task"). Preload("Task").
Preload("Images").
Where("id = ? AND is_active = ?", id, true). Where("id = ? AND is_active = ?", id, true).
First(&document).Error First(&document).Error
if err != nil { if err != nil {
@@ -35,6 +36,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
func (r *DocumentRepository) FindByResidence(residenceID uint) ([]models.Document, error) { func (r *DocumentRepository) FindByResidence(residenceID uint) ([]models.Document, error) {
var documents []models.Document var documents []models.Document
err := r.db.Preload("CreatedBy"). err := r.db.Preload("CreatedBy").
Preload("Images").
Where("residence_id = ? AND is_active = ?", residenceID, true). Where("residence_id = ? AND is_active = ?", residenceID, true).
Order("created_at DESC"). Order("created_at DESC").
Find(&documents).Error Find(&documents).Error
@@ -46,6 +48,7 @@ func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document,
var documents []models.Document var documents []models.Document
err := r.db.Preload("CreatedBy"). err := r.db.Preload("CreatedBy").
Preload("Residence"). Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ?", residenceIDs, true). Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
Order("created_at DESC"). Order("created_at DESC").
Find(&documents).Error Find(&documents).Error
@@ -57,6 +60,7 @@ func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Docum
var documents []models.Document var documents []models.Document
err := r.db.Preload("CreatedBy"). err := r.db.Preload("CreatedBy").
Preload("Residence"). Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ? AND document_type = ?", Where("residence_id IN ? AND is_active = ? AND document_type = ?",
residenceIDs, true, models.DocumentTypeWarranty). residenceIDs, true, models.DocumentTypeWarranty).
Order("expiry_date ASC NULLS LAST"). Order("expiry_date ASC NULLS LAST").
@@ -72,6 +76,7 @@ func (r *DocumentRepository) FindExpiringWarranties(residenceIDs []uint, days in
var documents []models.Document var documents []models.Document
err := r.db.Preload("CreatedBy"). err := r.db.Preload("CreatedBy").
Preload("Residence"). Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?", Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?",
residenceIDs, true, models.DocumentTypeWarranty, now, threshold). residenceIDs, true, models.DocumentTypeWarranty, now, threshold).
Order("expiry_date ASC"). Order("expiry_date ASC").
@@ -121,5 +126,20 @@ func (r *DocumentRepository) CountByResidence(residenceID uint) (int64, error) {
// FindByIDIncludingInactive finds a document by ID including inactive ones // FindByIDIncludingInactive finds a document by ID including inactive ones
func (r *DocumentRepository) FindByIDIncludingInactive(id uint, document *models.Document) error { func (r *DocumentRepository) FindByIDIncludingInactive(id uint, document *models.Document) error {
return r.db.Preload("CreatedBy").First(document, id).Error return r.db.Preload("CreatedBy").Preload("Images").First(document, id).Error
}
// CreateDocumentImage creates a new document image
func (r *DocumentRepository) CreateDocumentImage(image *models.DocumentImage) error {
return r.db.Create(image).Error
}
// DeleteDocumentImage deletes a document image
func (r *DocumentRepository) DeleteDocumentImage(id uint) error {
return r.db.Delete(&models.DocumentImage{}, id).Error
}
// DeleteDocumentImages deletes all images for a document
func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error {
return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentImage{}).Error
} }

View File

@@ -435,6 +435,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
var completion models.TaskCompletion var completion models.TaskCompletion
err := r.db.Preload("Task"). err := r.db.Preload("Task").
Preload("CompletedBy"). Preload("CompletedBy").
Preload("Images").
First(&completion, id).Error First(&completion, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -446,6 +447,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) { func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) {
var completions []models.TaskCompletion var completions []models.TaskCompletion
err := r.db.Preload("CompletedBy"). err := r.db.Preload("CompletedBy").
Preload("Images").
Where("task_id = ?", taskID). Where("task_id = ?", taskID).
Order("completed_at DESC"). Order("completed_at DESC").
Find(&completions).Error Find(&completions).Error
@@ -457,6 +459,7 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
var completions []models.TaskCompletion var completions []models.TaskCompletion
err := r.db.Preload("Task"). err := r.db.Preload("Task").
Preload("CompletedBy"). Preload("CompletedBy").
Preload("Images").
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id"). Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Where("task_task.residence_id IN ?", residenceIDs). Where("task_task.residence_id IN ?", residenceIDs).
Order("completed_at DESC"). Order("completed_at DESC").
@@ -466,9 +469,21 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
// DeleteCompletion deletes a task completion // DeleteCompletion deletes a task completion
func (r *TaskRepository) DeleteCompletion(id uint) error { func (r *TaskRepository) DeleteCompletion(id uint) error {
// Delete images first
r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{})
return r.db.Delete(&models.TaskCompletion{}, id).Error return r.db.Delete(&models.TaskCompletion{}, id).Error
} }
// CreateCompletionImage creates a new completion image
func (r *TaskRepository) CreateCompletionImage(image *models.TaskCompletionImage) error {
return r.db.Create(image).Error
}
// DeleteCompletionImage deletes a completion image
func (r *TaskRepository) DeleteCompletionImage(id uint) error {
return r.db.Delete(&models.TaskCompletionImage{}, id).Error
}
// TaskStatistics represents aggregated task statistics // TaskStatistics represents aggregated task statistics
type TaskStatistics struct { type TaskStatistics struct {
TotalTasks int TotalTasks int

View File

@@ -325,6 +325,7 @@ func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers
{ {
subscription.GET("/", subscriptionHandler.GetSubscription) subscription.GET("/", subscriptionHandler.GetSubscription)
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus) subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
subscription.GET("/upgrade-triggers/", subscriptionHandler.GetAllUpgradeTriggers)
subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger) subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger)
subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits) subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits)
subscription.GET("/promotions/", subscriptionHandler.GetPromotions) subscription.GET("/promotions/", subscriptionHandler.GetPromotions)

View File

@@ -142,6 +142,20 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
return nil, err return nil, err
} }
// Create images if provided
for _, imageURL := range req.ImageURLs {
if imageURL != "" {
img := &models.DocumentImage{
DocumentID: document.ID,
ImageURL: imageURL,
}
if err := s.documentRepo.CreateDocumentImage(img); err != nil {
// Log but don't fail the whole operation
continue
}
}
}
// Reload with relations // Reload with relations
document, err = s.documentRepo.FindByID(document.ID) document, err = s.documentRepo.FindByID(document.ID)
if err != nil { if err != nil {

View File

@@ -68,26 +68,52 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
return nil, err return nil, err
} }
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier) // Get all tier limits and build a map
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Get current usage if limitations are enabled limitsMap := make(map[string]*TierLimitsClientResponse)
var usage *UsageResponse for _, l := range allLimits {
if settings.EnableLimitations { limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l)
usage, err = s.getUserUsage(userID)
if err != nil {
return nil, err
}
} }
return &SubscriptionStatusResponse{ // Ensure both free and pro exist with defaults if missing
Subscription: NewSubscriptionResponse(sub), if _, ok := limitsMap["free"]; !ok {
Limits: NewTierLimitsResponse(limits), defaults := models.GetDefaultFreeLimits()
Usage: usage, limitsMap["free"] = NewTierLimitsClientResponse(&defaults)
}
if _, ok := limitsMap["pro"]; !ok {
defaults := models.GetDefaultProLimits()
limitsMap["pro"] = NewTierLimitsClientResponse(&defaults)
}
// Get current usage
usage, err := s.getUserUsage(userID)
if err != nil {
return nil, err
}
// Build flattened response (KMM expects subscription fields at top level)
resp := &SubscriptionStatusResponse{
AutoRenew: sub.AutoRenew,
Limits: limitsMap,
Usage: usage,
LimitationsEnabled: settings.EnableLimitations, LimitationsEnabled: settings.EnableLimitations,
}, nil }
// Format dates if present
if sub.SubscribedAt != nil {
t := sub.SubscribedAt.Format("2006-01-02T15:04:05Z")
resp.SubscribedAt = &t
}
if sub.ExpiresAt != nil {
t := sub.ExpiresAt.Format("2006-01-02T15:04:05Z")
resp.ExpiresAt = &t
}
return resp, nil
} }
// getUserUsage calculates current usage for a user // getUserUsage calculates current usage for a user
@@ -121,10 +147,10 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
} }
return &UsageResponse{ return &UsageResponse{
Properties: propertiesCount, PropertiesCount: propertiesCount,
Tasks: tasksCount, TasksCount: tasksCount,
Contractors: contractorsCount, ContractorsCount: contractorsCount,
Documents: documentsCount, DocumentsCount: documentsCount,
}, nil }, nil
} }
@@ -162,19 +188,19 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
switch limitType { switch limitType {
case "properties": case "properties":
if limits.PropertiesLimit != nil && usage.Properties >= int64(*limits.PropertiesLimit) { if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
return ErrPropertiesLimitExceeded return ErrPropertiesLimitExceeded
} }
case "tasks": case "tasks":
if limits.TasksLimit != nil && usage.Tasks >= int64(*limits.TasksLimit) { if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
return ErrTasksLimitExceeded return ErrTasksLimitExceeded
} }
case "contractors": case "contractors":
if limits.ContractorsLimit != nil && usage.Contractors >= int64(*limits.ContractorsLimit) { if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
return ErrContractorsLimitExceeded return ErrContractorsLimitExceeded
} }
case "documents": case "documents":
if limits.DocumentsLimit != nil && usage.Documents >= int64(*limits.DocumentsLimit) { if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
return ErrDocumentsLimitExceeded return ErrDocumentsLimitExceeded
} }
} }
@@ -194,6 +220,21 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
return NewUpgradeTriggerResponse(trigger), nil return NewUpgradeTriggerResponse(trigger), nil
} }
// GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key
// KMM client expects Map<String, UpgradeTriggerData>
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
if err != nil {
return nil, err
}
result := make(map[string]*UpgradeTriggerDataResponse)
for _, t := range triggers {
result[t.TriggerKey] = NewUpgradeTriggerDataResponse(&t)
}
return result, nil
}
// GetFeatureBenefits gets all feature benefits // GetFeatureBenefits gets all feature benefits
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) { func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
benefits, err := s.subscriptionRepo.GetFeatureBenefits() benefits, err := s.subscriptionRepo.GetFeatureBenefits()
@@ -331,23 +372,47 @@ func NewTierLimitsResponse(l *models.TierLimits) *TierLimitsResponse {
} }
} }
// UsageResponse represents current usage // UsageResponse represents current usage (KMM client expects _count suffix)
type UsageResponse struct { type UsageResponse struct {
Properties int64 `json:"properties"` PropertiesCount int64 `json:"properties_count"`
Tasks int64 `json:"tasks"` TasksCount int64 `json:"tasks_count"`
Contractors int64 `json:"contractors"` ContractorsCount int64 `json:"contractors_count"`
Documents int64 `json:"documents"` DocumentsCount int64 `json:"documents_count"`
}
// TierLimitsClientResponse represents tier limits for mobile client (simple field names)
type TierLimitsClientResponse struct {
Properties *int `json:"properties"`
Tasks *int `json:"tasks"`
Contractors *int `json:"contractors"`
Documents *int `json:"documents"`
}
// NewTierLimitsClientResponse creates a TierLimitsClientResponse from a model
func NewTierLimitsClientResponse(l *models.TierLimits) *TierLimitsClientResponse {
return &TierLimitsClientResponse{
Properties: l.PropertiesLimit,
Tasks: l.TasksLimit,
Contractors: l.ContractorsLimit,
Documents: l.DocumentsLimit,
}
} }
// SubscriptionStatusResponse represents full subscription status // SubscriptionStatusResponse represents full subscription status
// Fields are flattened to match KMM client expectations
type SubscriptionStatusResponse struct { type SubscriptionStatusResponse struct {
Subscription *SubscriptionResponse `json:"subscription"` // Flattened subscription fields (KMM expects these at top level)
Limits *TierLimitsResponse `json:"limits"` SubscribedAt *string `json:"subscribed_at"`
Usage *UsageResponse `json:"usage,omitempty"` ExpiresAt *string `json:"expires_at"`
LimitationsEnabled bool `json:"limitations_enabled"` AutoRenew bool `json:"auto_renew"`
// Other fields
Usage *UsageResponse `json:"usage"`
Limits map[string]*TierLimitsClientResponse `json:"limits"`
LimitationsEnabled bool `json:"limitations_enabled"`
} }
// UpgradeTriggerResponse represents an upgrade trigger // UpgradeTriggerResponse represents an upgrade trigger (includes trigger_key)
type UpgradeTriggerResponse struct { type UpgradeTriggerResponse struct {
TriggerKey string `json:"trigger_key"` TriggerKey string `json:"trigger_key"`
Title string `json:"title"` Title string `json:"title"`
@@ -367,6 +432,29 @@ func NewUpgradeTriggerResponse(t *models.UpgradeTrigger) *UpgradeTriggerResponse
} }
} }
// UpgradeTriggerDataResponse represents trigger data for map values (no trigger_key)
// Matches KMM UpgradeTriggerData model
type UpgradeTriggerDataResponse struct {
Title string `json:"title"`
Message string `json:"message"`
PromoHTML *string `json:"promo_html"`
ButtonText string `json:"button_text"`
}
// NewUpgradeTriggerDataResponse creates an UpgradeTriggerDataResponse from a model
func NewUpgradeTriggerDataResponse(t *models.UpgradeTrigger) *UpgradeTriggerDataResponse {
var promoHTML *string
if t.PromoHTML != "" {
promoHTML = &t.PromoHTML
}
return &UpgradeTriggerDataResponse{
Title: t.Title,
Message: t.Message,
PromoHTML: promoHTML,
ButtonText: t.ButtonText,
}
}
// FeatureBenefitResponse represents a feature benefit // FeatureBenefitResponse represents a feature benefit
type FeatureBenefitResponse struct { type FeatureBenefitResponse struct {
FeatureName string `json:"feature_name"` FeatureName string `json:"feature_name"`

View File

@@ -475,14 +475,27 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
CompletedAt: completedAt, CompletedAt: completedAt,
Notes: req.Notes, Notes: req.Notes,
ActualCost: req.ActualCost, ActualCost: req.ActualCost,
PhotoURL: req.PhotoURL, Rating: req.Rating,
} }
if err := s.taskRepo.CreateCompletion(completion); err != nil { if err := s.taskRepo.CreateCompletion(completion); err != nil {
return nil, err return nil, err
} }
// Reload completion with user info // Create images if provided
for _, imageURL := range req.ImageURLs {
if imageURL != "" {
img := &models.TaskCompletionImage{
CompletionID: completion.ID,
ImageURL: imageURL,
}
if err := s.taskRepo.CreateCompletionImage(img); err != nil {
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image")
}
}
}
// Reload completion with user info and images
completion, err = s.taskRepo.FindCompletionByID(completion.ID) completion, err = s.taskRepo.FindCompletionByID(completion.ID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -1,153 +1,605 @@
-- Seed test data for MyCrib -- Seed test data for MyCrib
-- Run with: ./dev.sh seed-test -- Run with: POST /api/admin/settings/seed-test-data
-- Note: Run ./dev.sh seed first to populate lookup tables -- Note: Run seed-lookups first to populate lookup tables
-- Test Users (password is 'password123' hashed with bcrypt) -- =====================================================
-- bcrypt hash for 'password123': $2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi -- TEST USERS (password is 'password123' for all users)
INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined) -- bcrypt hash: $2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi
-- =====================================================
INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined, last_login)
VALUES VALUES
(1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@example.com', 'Admin', 'User', true, true, true, NOW()), (1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@mycrib.com', 'Admin', 'User', true, true, true, NOW() - INTERVAL '1 year', NOW() - INTERVAL '1 hour'),
(2, 'john', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john@example.com', 'John', 'Doe', true, false, false, NOW()), (2, 'john.doe', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john.doe@example.com', 'John', 'Doe', true, false, false, NOW() - INTERVAL '6 months', NOW() - INTERVAL '2 hours'),
(3, 'jane', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane@example.com', 'Jane', 'Smith', true, false, false, NOW()), (3, 'jane.smith', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane.smith@example.com', 'Jane', 'Smith', true, false, false, NOW() - INTERVAL '5 months', NOW() - INTERVAL '1 day'),
(4, 'bob', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob@example.com', 'Bob', 'Wilson', true, false, false, NOW()) (4, 'bob.wilson', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob.wilson@example.com', 'Bob', 'Wilson', true, false, false, NOW() - INTERVAL '4 months', NOW() - INTERVAL '3 days'),
(5, 'alice.johnson', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'alice.johnson@example.com', 'Alice', 'Johnson', true, false, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '1 week'),
(6, 'charlie.brown', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'charlie.brown@example.com', 'Charlie', 'Brown', true, false, false, NOW() - INTERVAL '2 months', NOW() - INTERVAL '2 weeks'),
(7, 'diana.ross', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'diana.ross@example.com', 'Diana', 'Ross', true, false, false, NOW() - INTERVAL '1 month', NULL),
(8, 'edward.norton', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'edward.norton@example.com', 'Edward', 'Norton', true, false, false, NOW() - INTERVAL '2 weeks', NOW() - INTERVAL '5 days'),
(9, 'fiona.apple', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'fiona.apple@example.com', 'Fiona', 'Apple', true, false, false, NOW() - INTERVAL '1 week', NOW()),
(10, 'inactive.user', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'inactive@example.com', 'Inactive', 'User', false, false, false, NOW() - INTERVAL '1 year', NULL),
(11, 'staff.member', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'staff@mycrib.com', 'Staff', 'Member', true, true, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '6 hours'),
(12, 'george.harrison', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'george@example.com', 'George', 'Harrison', true, false, false, NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days')
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username, username = EXCLUDED.username, password = EXCLUDED.password, email = EXCLUDED.email,
password = EXCLUDED.password, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, is_active = EXCLUDED.is_active,
email = EXCLUDED.email, is_staff = EXCLUDED.is_staff, is_superuser = EXCLUDED.is_superuser;
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
is_active = EXCLUDED.is_active;
-- User Profiles (email verified) -- =====================================================
-- USER PROFILES
-- =====================================================
INSERT INTO user_userprofile (id, created_at, updated_at, user_id, verified, bio, phone_number, date_of_birth, profile_picture) INSERT INTO user_userprofile (id, created_at, updated_at, user_id, verified, bio, phone_number, date_of_birth, profile_picture)
VALUES VALUES
(1, NOW(), NOW(), 1, true, '', '', NULL, ''), (1, NOW(), NOW(), 1, true, 'System administrator', '+1-555-0001', '1985-01-15', 'https://picsum.photos/seed/admin/200'),
(2, NOW(), NOW(), 2, true, '', '', NULL, ''), (2, NOW(), NOW(), 2, true, 'Homeowner with multiple properties', '+1-555-0002', '1980-03-22', 'https://picsum.photos/seed/john/200'),
(3, NOW(), NOW(), 3, true, '', '', NULL, ''), (3, NOW(), NOW(), 3, true, 'First-time homeowner', '+1-555-0003', '1992-07-08', 'https://picsum.photos/seed/jane/200'),
(4, NOW(), NOW(), 4, true, '', '', NULL, '') (4, NOW(), NOW(), 4, true, 'Real estate investor', '+1-555-0004', '1975-11-30', 'https://picsum.photos/seed/bob/200'),
ON CONFLICT (user_id) DO UPDATE SET (5, NOW(), NOW(), 5, true, 'Property manager', '+1-555-0005', '1988-05-17', 'https://picsum.photos/seed/alice/200'),
verified = true, (6, NOW(), NOW(), 6, false, '', '+1-555-0006', NULL, ''),
updated_at = NOW(); (7, NOW(), NOW(), 7, false, 'New to the app', '', NULL, ''),
(8, NOW(), NOW(), 8, true, 'DIY enthusiast', '+1-555-0008', '1990-09-25', 'https://picsum.photos/seed/edward/200'),
(9, NOW(), NOW(), 9, true, 'Interior designer', '+1-555-0009', '1995-02-14', 'https://picsum.photos/seed/fiona/200'),
(10, NOW(), NOW(), 10, false, '', '', NULL, ''),
(11, NOW(), NOW(), 11, true, 'Customer support', '+1-555-0011', '1987-06-20', 'https://picsum.photos/seed/staff/200'),
(12, NOW(), NOW(), 12, true, 'Weekend warrior', '+1-555-0012', '1982-12-05', 'https://picsum.photos/seed/george/200')
ON CONFLICT (user_id) DO UPDATE SET verified = EXCLUDED.verified, bio = EXCLUDED.bio, updated_at = NOW();
-- User Subscriptions -- =====================================================
INSERT INTO subscription_usersubscription (id, created_at, updated_at, user_id, tier, subscribed_at, expires_at, auto_renew, platform) -- USER SUBSCRIPTIONS (mix of free and pro, various platforms)
-- =====================================================
INSERT INTO subscription_usersubscription (id, created_at, updated_at, user_id, tier, subscribed_at, expires_at, auto_renew, platform, cancelled_at)
VALUES VALUES
(1, NOW(), NOW(), 1, 'pro', NOW(), NOW() + INTERVAL '1 year', true, 'ios'), (1, NOW(), NOW(), 1, 'pro', NOW() - INTERVAL '11 months', NOW() + INTERVAL '1 month', true, 'ios', NULL),
(2, NOW(), NOW(), 2, 'pro', NOW(), NOW() + INTERVAL '1 year', true, 'android'), (2, NOW(), NOW(), 2, 'pro', NOW() - INTERVAL '6 months', NOW() + INTERVAL '6 months', true, 'android', NULL),
(3, NOW(), NOW(), 3, 'free', NULL, NULL, false, NULL), (3, NOW(), NOW(), 3, 'free', NULL, NULL, false, NULL, NULL),
(4, NOW(), NOW(), 4, 'free', NULL, NULL, false, NULL) (4, NOW(), NOW(), 4, 'pro', NOW() - INTERVAL '3 months', NOW() + INTERVAL '9 months', true, 'ios', NULL),
ON CONFLICT (id) DO UPDATE SET (5, NOW(), NOW(), 5, 'pro', NOW() - INTERVAL '1 month', NOW() + INTERVAL '11 months', false, 'android', NULL),
tier = EXCLUDED.tier, (6, NOW(), NOW(), 6, 'free', NULL, NULL, false, NULL, NULL),
updated_at = NOW(); (7, NOW(), NOW(), 7, 'free', NULL, NULL, false, NULL, NULL),
(8, NOW(), NOW(), 8, 'pro', NOW() - INTERVAL '2 months', NOW() - INTERVAL '1 month', false, 'ios', NOW() - INTERVAL '1 month'),
(9, NOW(), NOW(), 9, 'pro', NOW() - INTERVAL '1 week', NOW() + INTERVAL '1 year', true, 'android', NULL),
(10, NOW(), NOW(), 10, 'free', NULL, NULL, false, NULL, NULL),
(11, NOW(), NOW(), 11, 'pro', NOW() - INTERVAL '6 months', NOW() + INTERVAL '6 months', true, 'ios', NULL),
(12, NOW(), NOW(), 12, 'free', NULL, NULL, false, NULL, NULL)
ON CONFLICT (id) DO UPDATE SET tier = EXCLUDED.tier, updated_at = NOW();
-- Test Residences (using Go/GORM schema: street_address, state_province, postal_code) -- =====================================================
INSERT INTO residence_residence (id, created_at, updated_at, owner_id, property_type_id, name, street_address, city, state_province, postal_code, country, is_active, is_primary) -- RESIDENCES (all property types, various locations)
-- Property types: 1=House, 2=Apartment, 3=Condo, 4=Townhouse, 5=Mobile Home, 6=Multi-Family, 7=Vacation Home
-- =====================================================
INSERT INTO residence_residence (id, created_at, updated_at, owner_id, property_type_id, name, street_address, apartment_unit, city, state_province, postal_code, country, bedrooms, bathrooms, square_footage, year_built, description, is_active, is_primary)
VALUES VALUES
(1, NOW(), NOW(), 2, 1, 'Main House', '123 Main Street', 'Springfield', 'IL', '62701', 'USA', true, true), -- John's properties (user 2)
(2, NOW(), NOW(), 2, 7, 'Beach House', '456 Ocean Drive', 'Miami', 'FL', '33139', 'USA', true, false), (1, NOW() - INTERVAL '6 months', NOW(), 2, 1, 'Main Family Home', '123 Oak Street', NULL, 'Springfield', 'IL', '62701', 'USA', 4, 3, 2500, 1995, 'Beautiful colonial style home with large backyard', true, true),
(3, NOW(), NOW(), 3, 2, 'Downtown Apartment', '789 City Center', 'Los Angeles', 'CA', '90012', 'USA', true, true), (2, NOW() - INTERVAL '5 months', NOW(), 2, 7, 'Beach Getaway', '456 Ocean Boulevard', NULL, 'Miami Beach', 'FL', '33139', 'USA', 3, 2, 1800, 2010, 'Oceanfront vacation property', true, false),
(4, NOW(), NOW(), 4, 3, 'Mountain Condo', '321 Peak View', 'Denver', 'CO', '80202', 'USA', true, true) (3, NOW() - INTERVAL '4 months', NOW(), 2, 6, 'Investment Duplex', '789 Rental Road', NULL, 'Chicago', 'IL', '60601', 'USA', 4, 2, 2200, 1985, 'Duplex rental property', true, false),
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
street_address = EXCLUDED.street_address,
updated_at = NOW();
-- Share residence 1 with user 3 -- Jane's properties (user 3)
(4, NOW() - INTERVAL '5 months', NOW(), 3, 2, 'Downtown Loft', '100 City Center', 'Unit 15B', 'Los Angeles', 'CA', '90012', 'USA', 2, 2, 1200, 2018, 'Modern loft in downtown LA', true, true),
-- Bob's properties (user 4)
(5, NOW() - INTERVAL '4 months', NOW(), 4, 3, 'Mountain View Condo', '321 Peak Drive', 'Unit 8', 'Denver', 'CO', '80202', 'USA', 2, 2, 1500, 2015, 'Ski-in/ski-out condo', true, true),
(6, NOW() - INTERVAL '3 months', NOW(), 4, 4, 'Suburban Townhouse', '555 Maple Lane', NULL, 'Aurora', 'CO', '80010', 'USA', 3, 3, 1900, 2008, 'End-unit townhouse with garage', true, false),
(7, NOW() - INTERVAL '2 months', NOW(), 4, 1, 'Rental House', '777 Income Ave', NULL, 'Boulder', 'CO', '80301', 'USA', 3, 2, 1600, 1990, 'Single family rental', true, false),
-- Alice's properties (user 5)
(8, NOW() - INTERVAL '3 months', NOW(), 5, 1, 'Craftsman Bungalow', '888 Artisan Way', NULL, 'Portland', 'OR', '97201', 'USA', 3, 2, 1700, 1925, 'Historic craftsman with modern updates', true, true),
(9, NOW() - INTERVAL '2 months', NOW(), 5, 2, 'River View Apartment', '999 Waterfront Dr', 'Apt 22', 'Portland', 'OR', '97209', 'USA', 1, 1, 750, 2020, 'Luxury apartment with river views', true, false),
-- Charlie's property (user 6)
(10, NOW() - INTERVAL '2 months', NOW(), 6, 5, 'Lakeside Mobile', '111 Lakeshore Park', 'Lot 45', 'Austin', 'TX', '78701', 'USA', 2, 1, 900, 2005, 'Mobile home in lakeside community', true, true),
-- Edward's property (user 8)
(11, NOW() - INTERVAL '2 weeks', NOW(), 8, 1, 'Fixer Upper', '222 Project Street', NULL, 'Seattle', 'WA', '98101', 'USA', 3, 1, 1400, 1970, 'Renovation project in progress', true, true),
-- Fiona's property (user 9)
(12, NOW() - INTERVAL '1 week', NOW(), 9, 2, 'Designer Studio', '333 Arts District', 'Studio 7', 'San Francisco', 'CA', '94102', 'USA', 0, 1, 600, 2022, 'Modern studio in arts district', true, true),
-- George's property (user 12)
(13, NOW() - INTERVAL '45 days', NOW(), 12, 4, 'Quiet Townhome', '444 Suburban Way', NULL, 'Scottsdale', 'AZ', '85251', 'USA', 3, 3, 2000, 2012, 'Family-friendly townhome', true, true),
-- Inactive residence
(14, NOW() - INTERVAL '1 year', NOW(), 4, 1, 'Sold Property', '666 Former Home', NULL, 'Phoenix', 'AZ', '85001', 'USA', 2, 1, 1100, 1980, 'Previously owned', false, false)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = NOW();
-- =====================================================
-- RESIDENCE SHARING (multi-user access)
-- =====================================================
INSERT INTO residence_residence_users (residence_id, user_id) INSERT INTO residence_residence_users (residence_id, user_id)
VALUES (1, 3) VALUES
(1, 3), -- Jane has access to John's main home
(1, 5), -- Alice has access to John's main home
(2, 3), -- Jane has access to Beach house
(5, 12), -- George has access to Bob's condo
(8, 9) -- Fiona has access to Alice's bungalow
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Test Contractors -- =====================================================
INSERT INTO task_contractor (id, created_at, updated_at, residence_id, created_by_id, name, company, phone, email, website, notes, is_favorite, is_active) -- CONTRACTORS (all specialties, various ratings and details)
-- Specialties: 1=Plumber, 2=Electrician, 3=HVAC, 4=Handyman, 5=Roofer, 6=Painter, 7=Landscaper, 8=Carpenter, 9=Appliance Repair, 10=Locksmith
-- =====================================================
INSERT INTO task_contractor (id, created_at, updated_at, residence_id, created_by_id, name, company, phone, email, website, notes, street_address, city, state_province, postal_code, rating, is_favorite, is_active)
VALUES VALUES
(1, NOW(), NOW(), 1, 2, 'Mike the Plumber', 'Mike''s Plumbing Co.', '+1-555-1001', 'mike@plumbing.com', 'https://mikesplumbing.com', 'Great service, always on time', true, true), -- John's contractors (residence 1)
(2, NOW(), NOW(), 1, 2, 'Sparky Electric', 'Sparky Electrical Services', '+1-555-1002', 'info@sparky.com', NULL, 'Licensed and insured', false, true), (1, NOW(), NOW(), 1, 2, 'Mike Johnson', 'Mike''s Plumbing Pro', '+1-555-1001', 'mike@plumbingpro.com', 'https://mikesplumbingpro.com', 'Best plumber in town. Always on time. 24/7 emergency service available.', '100 Trade Center', 'Springfield', 'IL', '62702', 4.9, true, true),
(3, NOW(), NOW(), 1, 2, 'Cool Air HVAC', 'Cool Air Heating & Cooling', '+1-555-1003', 'service@coolair.com', 'https://coolair.com', '24/7 emergency service', true, true), (2, NOW(), NOW(), 1, 2, 'Sarah Electric', 'Sparky Electrical Services', '+1-555-1002', 'sarah@sparkyelectric.com', 'https://sparkyelectric.com', 'Licensed master electrician. Specializes in panel upgrades.', '200 Industrial Blvd', 'Springfield', 'IL', '62703', 4.7, false, true),
(4, NOW(), NOW(), 3, 3, 'Handy Andy', NULL, '+1-555-1004', 'andy@handyman.com', NULL, 'General repairs', false, true) (3, NOW(), NOW(), 1, 2, 'Cool Air Team', 'Arctic Comfort HVAC', '+1-555-1003', 'service@arcticcomfort.com', 'https://arcticcomfort.com', 'Annual maintenance contract available. Fast response time.', '300 Climate Way', 'Springfield', 'IL', '62704', 4.8, true, true),
ON CONFLICT (id) DO UPDATE SET (4, NOW(), NOW(), 1, 2, 'Tom the Handyman', NULL, '+1-555-1004', 'tom.handyman@gmail.com', NULL, 'Jack of all trades. Great for small jobs.', NULL, 'Springfield', 'IL', NULL, 4.5, false, true),
name = EXCLUDED.name, (5, NOW(), NOW(), 1, 2, 'Green Thumb Landscaping', 'Green Thumb LLC', '+1-555-1005', 'info@greenthumbland.com', 'https://greenthumbland.com', 'Weekly lawn service and seasonal plantings.', '400 Garden Path', 'Springfield', 'IL', '62705', 4.6, true, true),
company = EXCLUDED.company,
updated_at = NOW(); -- Beach house contractors (residence 2)
(6, NOW(), NOW(), 2, 2, 'Beach Plumbing Co', 'Coastal Plumbing', '+1-305-2001', 'coastal@plumbing.com', NULL, 'Specializes in salt-water corrosion issues.', '500 Shore Dr', 'Miami Beach', 'FL', '33140', 4.4, false, true),
(7, NOW(), NOW(), 2, 2, 'Hurricane Shutters Pro', 'Storm Ready Inc', '+1-305-2002', 'ready@stormready.com', 'https://stormready.com', 'Hurricane prep and shutter installation.', '600 Storm Lane', 'Miami', 'FL', '33141', 4.9, true, true),
-- Jane's contractors (residence 4)
(8, NOW(), NOW(), 4, 3, 'LA Maintenance', 'Downtown Services LLC', '+1-213-3001', 'support@dtservices.com', NULL, 'Building-approved contractor for condo repairs.', '700 Commerce St', 'Los Angeles', 'CA', '90013', 4.3, false, true),
(9, NOW(), NOW(), 4, 3, 'Appliance Doctor', 'Fix-It Appliances', '+1-213-3002', 'fixit@appliances.com', 'https://fixitappliances.com', 'All major brands. Same-day service available.', '800 Repair Rd', 'Los Angeles', 'CA', '90014', 4.7, true, true),
-- Bob's contractors (residences 5, 6, 7)
(10, NOW(), NOW(), 5, 4, 'Mountain Roof Experts', 'Peak Roofing', '+1-303-4001', 'peak@roofing.com', 'https://peakroofing.com', 'Snow load specialists. Emergency repairs.', '900 Summit Ave', 'Denver', 'CO', '80203', 4.8, true, true),
(11, NOW(), NOW(), 5, 4, 'Colorado Paint Crew', 'Altitude Painters', '+1-303-4002', 'paint@altitudepainters.com', NULL, 'Interior and exterior. Color consultation included.', '1000 Brush St', 'Denver', 'CO', '80204', 4.5, false, true),
(12, NOW(), NOW(), 6, 4, 'Garage Door Kings', 'Royal Doors Inc', '+1-303-4003', 'royal@doorsking.com', 'https://royaldoors.com', 'Installation and repair of all garage door types.', '1100 Entry Way', 'Aurora', 'CO', '80011', 4.6, false, true),
-- Alice's contractors (residences 8, 9)
(13, NOW(), NOW(), 8, 5, 'Historic Home Specialist', 'Preservation Builders', '+1-503-5001', 'info@preservationbuilders.com', 'https://preservationbuilders.com', 'Specializes in historic home restoration and repairs.', '1200 Heritage Ln', 'Portland', 'OR', '97202', 4.9, true, true),
(14, NOW(), NOW(), 8, 5, 'Eco Plumbing', 'Green Flow Plumbing', '+1-503-5002', 'green@flowplumbing.com', NULL, 'Eco-friendly solutions. Water conservation experts.', '1300 Eco Way', 'Portland', 'OR', '97203', 4.4, false, true),
-- Edward's contractors (residence 11)
(15, NOW(), NOW(), 11, 8, 'Demo Dave', 'Demolition & Renovation', '+1-206-6001', 'dave@demoreno.com', NULL, 'Demolition and structural work for renovations.', '1400 Build St', 'Seattle', 'WA', '98102', 4.2, false, true),
(16, NOW(), NOW(), 11, 8, 'Master Carpenter', 'Woodworks Plus', '+1-206-6002', 'craft@woodworksplus.com', 'https://woodworksplus.com', 'Custom cabinetry and finish carpentry.', '1500 Timber Rd', 'Seattle', 'WA', '98103', 4.8, true, true),
-- Fiona's contractors (residence 12)
(17, NOW(), NOW(), 12, 9, 'Smart Home Installer', 'Tech Home Solutions', '+1-415-7001', 'smart@techhome.com', 'https://techhome.com', 'Smart home device installation and setup.', '1600 Digital Dr', 'San Francisco', 'CA', '94103', 4.6, true, true),
-- George's contractors (residence 13)
(18, NOW(), NOW(), 13, 12, 'Desert Cooling', 'Cactus HVAC', '+1-480-8001', 'cool@cactushvac.com', 'https://cactushvac.com', 'Experts in desert climate cooling systems.', '1700 Heat Wave Ave', 'Scottsdale', 'AZ', '85252', 4.7, true, true),
(19, NOW(), NOW(), 13, 12, 'Pool Pro AZ', 'Aqua Services', '+1-480-8002', 'pool@aquaservices.com', NULL, 'Pool maintenance and equipment repair.', '1800 Splash Ln', 'Scottsdale', 'AZ', '85253', 4.5, false, true),
-- Inactive contractor
(20, NOW(), NOW(), 1, 2, 'Retired Roofer', 'Old Roof Co', '+1-555-9999', NULL, NULL, 'No longer in business', NULL, 'Springfield', 'IL', NULL, 3.5, false, false)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = NOW();
-- Contractor Specialties (many-to-many) -- Contractor Specialties (many-to-many)
INSERT INTO task_contractor_specialties (contractor_id, contractor_specialty_id) INSERT INTO task_contractor_specialties (contractor_id, contractor_specialty_id)
VALUES VALUES
(1, 1), -- Mike: Plumber (1, 1), -- Mike: Plumber
(2, 2), -- Sparky: Electrician (2, 2), -- Sarah: Electrician
(3, 3), -- Cool Air: HVAC (3, 3), -- Cool Air: HVAC
(4, 4), -- Andy: Handyman (4, 4), (4, 6), (4, 8), -- Tom: Handyman, Painter, Carpenter
(4, 6) -- Andy: Also Painter (5, 7), -- Green Thumb: Landscaper
(6, 1), -- Beach Plumbing: Plumber
(7, 4), (7, 8), -- Hurricane: Handyman, Carpenter
(8, 4), (8, 1), (8, 2), -- LA Maintenance: Handyman, Plumber, Electrician
(9, 9), -- Appliance Doctor: Appliance Repair
(10, 5), -- Mountain Roof: Roofer
(11, 6), -- Colorado Paint: Painter
(12, 4), (12, 8), -- Garage Door: Handyman, Carpenter
(13, 8), (13, 6), -- Historic: Carpenter, Painter
(14, 1), -- Eco Plumbing: Plumber
(15, 4), (15, 8), -- Demo Dave: Handyman, Carpenter
(16, 8), -- Master Carpenter: Carpenter
(17, 2), (17, 4), -- Smart Home: Electrician, Handyman
(18, 3), -- Desert Cooling: HVAC
(19, 4), -- Pool Pro: Handyman
(20, 5) -- Retired Roofer: Roofer
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Test Tasks -- =====================================================
-- TASKS (comprehensive coverage of all categories, priorities, statuses, frequencies)
-- Categories: 1=Plumbing, 2=Electrical, 3=HVAC, 4=Appliances, 5=Outdoor/Landscaping, 6=Structural, 7=Safety, 8=Cleaning, 9=Pest Control, 10=Other
-- Priorities: 1=Low, 2=Medium, 3=High, 4=Urgent
-- Statuses: 1=Pending, 2=In Progress, 3=Completed, 4=On Hold, 5=Cancelled
-- Frequencies: 1=Once, 2=Daily, 3=Weekly, 4=Bi-Weekly, 5=Monthly, 6=Quarterly, 7=Semi-Annual, 8=Annual
-- =====================================================
INSERT INTO task_task (id, created_at, updated_at, residence_id, created_by_id, assigned_to_id, title, description, category_id, priority_id, status_id, frequency_id, due_date, estimated_cost, contractor_id, is_cancelled, is_archived) INSERT INTO task_task (id, created_at, updated_at, residence_id, created_by_id, assigned_to_id, title, description, category_id, priority_id, status_id, frequency_id, due_date, estimated_cost, contractor_id, is_cancelled, is_archived)
VALUES VALUES
-- Residence 1 tasks -- ===== RESIDENCE 1 (John's Main Home) - 15 tasks =====
(1, NOW(), NOW(), 1, 2, 2, 'Fix leaky faucet', 'Kitchen faucet is dripping', 1, 2, 1, 1, CURRENT_DATE + INTERVAL '7 days', 150.00, 1, false, false), -- Plumbing tasks
(2, NOW(), NOW(), 1, 2, NULL, 'Replace smoke detector batteries', 'Annual battery replacement', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '30 days', 25.00, NULL, false, false), (1, NOW() - INTERVAL '30 days', NOW(), 1, 2, 2, 'Fix leaky kitchen faucet', 'Kitchen faucet has been dripping for a week. Washer may need replacement.', 1, 2, 1, 1, CURRENT_DATE + INTERVAL '7 days', 150.00, 1, false, false),
(3, NOW(), NOW(), 1, 2, 2, 'HVAC filter replacement', 'Replace air filters', 3, 2, 2, 5, CURRENT_DATE + INTERVAL '14 days', 50.00, 3, false, false), (2, NOW() - INTERVAL '60 days', NOW(), 1, 2, 3, 'Unclog bathroom drain', 'Master bathroom sink draining slowly. Tried Drano with no success.', 1, 3, 3, 1, CURRENT_DATE - INTERVAL '45 days', 85.00, 1, false, false),
(4, NOW(), NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn maintenance', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '2 days', NULL, NULL, false, false), (3, NOW() - INTERVAL '15 days', NOW(), 1, 2, NULL, 'Water heater inspection', 'Annual inspection and flush of water heater tank', 1, 1, 1, 8, CURRENT_DATE + INTERVAL '30 days', 175.00, 1, false, false),
(5, NOW(), NOW(), 1, 2, NULL, 'Clean gutters', 'Remove leaves and debris', 5, 2, 1, 7, CURRENT_DATE + INTERVAL '60 days', 200.00, NULL, false, false),
-- Residence 2 tasks -- Electrical tasks
(6, NOW(), NOW(), 2, 2, 2, 'Check pool chemicals', 'Test and balance pool water', 10, 2, 1, 3, CURRENT_DATE + INTERVAL '3 days', NULL, NULL, false, false), (4, NOW() - INTERVAL '20 days', NOW(), 1, 2, 2, 'Install ceiling fan', 'Replace light fixture in master bedroom with ceiling fan', 2, 2, 2, 1, CURRENT_DATE + INTERVAL '14 days', 350.00, 2, false, false),
(7, NOW(), NOW(), 2, 2, NULL, 'Hurricane shutters inspection', 'Annual inspection before hurricane season', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '90 days', NULL, NULL, false, false), (5, NOW() - INTERVAL '90 days', NOW(), 1, 2, NULL, 'Replace smoke detector batteries', 'Replace batteries in all 6 smoke detectors', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '60 days', 30.00, NULL, false, false),
(6, NOW() - INTERVAL '5 days', NOW(), 1, 2, NULL, 'Fix flickering lights', 'Living room lights flicker occasionally. May need new switch.', 2, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', 125.00, 2, false, false),
-- Residence 3 tasks -- HVAC tasks
(8, NOW(), NOW(), 3, 3, 3, 'Fix garbage disposal', 'Disposal is jammed', 4, 3, 1, 1, CURRENT_DATE + INTERVAL '2 days', 100.00, NULL, false, false), (7, NOW() - INTERVAL '45 days', NOW(), 1, 2, 2, 'Replace HVAC filters', 'Monthly filter replacement for central air system', 3, 2, 3, 5, CURRENT_DATE - INTERVAL '15 days', 45.00, 3, false, false),
(9, NOW(), NOW(), 3, 3, NULL, 'Deep clean apartment', 'Quarterly deep cleaning', 8, 1, 1, 6, CURRENT_DATE + INTERVAL '45 days', 300.00, NULL, false, false), (8, NOW() - INTERVAL '10 days', NOW(), 1, 2, NULL, 'Schedule AC tune-up', 'Pre-summer AC maintenance and coolant check', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '60 days', 200.00, 3, false, false),
-- Residence 4 tasks -- Outdoor/Landscaping tasks
(10, NOW(), NOW(), 4, 4, 4, 'Winterize pipes', 'Prepare plumbing for winter', 1, 3, 1, 8, CURRENT_DATE + INTERVAL '120 days', 250.00, NULL, false, false) (9, NOW() - INTERVAL '7 days', NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn mowing and edging', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '2 days', 50.00, 5, false, false),
ON CONFLICT (id) DO UPDATE SET (10, NOW() - INTERVAL '25 days', NOW(), 1, 2, NULL, 'Trim hedges', 'Trim front and back yard hedges', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '20 days', 150.00, 5, false, false),
title = EXCLUDED.title, (11, NOW() - INTERVAL '100 days', NOW(), 1, 2, 5, 'Clean gutters', 'Remove leaves and debris from all gutters', 5, 2, 3, 7, CURRENT_DATE - INTERVAL '70 days', 175.00, NULL, false, false),
description = EXCLUDED.description, (12, NOW() - INTERVAL '3 days', NOW(), 1, 2, NULL, 'Fertilize lawn', 'Apply spring fertilizer treatment', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '5 days', 75.00, 5, false, false),
updated_at = NOW();
-- Test Task Completions -- Safety tasks
INSERT INTO task_taskcompletion (id, created_at, updated_at, task_id, completed_by_id, completed_at, notes, actual_cost) (13, NOW() - INTERVAL '180 days', NOW(), 1, 2, 2, 'Test fire extinguishers', 'Annual inspection of all fire extinguishers', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '150 days', 0.00, NULL, false, false),
-- Cleaning tasks
(14, NOW() - INTERVAL '40 days', NOW(), 1, 2, 3, 'Deep clean carpets', 'Professional carpet cleaning for all rooms', 8, 1, 3, 8, CURRENT_DATE - INTERVAL '25 days', 350.00, NULL, false, false),
-- On hold task
(15, NOW() - INTERVAL '50 days', NOW(), 1, 2, NULL, 'Paint exterior', 'Repaint exterior trim and shutters', 6, 2, 4, 1, CURRENT_DATE + INTERVAL '90 days', 2500.00, NULL, false, false),
-- ===== RESIDENCE 2 (Beach House) - 8 tasks =====
(16, NOW() - INTERVAL '20 days', NOW(), 2, 2, 2, 'Check pool chemicals', 'Weekly pool water testing and chemical balance', 10, 2, 3, 3, CURRENT_DATE - INTERVAL '6 days', 50.00, NULL, false, false),
(17, NOW() - INTERVAL '15 days', NOW(), 2, 2, NULL, 'Hurricane shutter inspection', 'Annual inspection before hurricane season', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '45 days', 300.00, 7, false, false),
(18, NOW() - INTERVAL '30 days', NOW(), 2, 2, NULL, 'AC filter change', 'Replace AC filters - salt air requires more frequent changes', 3, 2, 1, 5, CURRENT_DATE + INTERVAL '5 days', 60.00, NULL, false, false),
(19, NOW() - INTERVAL '60 days', NOW(), 2, 2, 2, 'Fix outdoor shower', 'Outdoor shower has low pressure', 1, 2, 3, 1, CURRENT_DATE - INTERVAL '40 days', 200.00, 6, false, false),
(20, NOW() - INTERVAL '5 days', NOW(), 2, 2, NULL, 'Pressure wash deck', 'Clean salt buildup from deck and railings', 8, 1, 1, 6, CURRENT_DATE + INTERVAL '30 days', 250.00, NULL, false, false),
(21, NOW() - INTERVAL '90 days', NOW(), 2, 2, NULL, 'Pest control treatment', 'Quarterly pest control for beach property', 9, 2, 3, 6, CURRENT_DATE - INTERVAL '60 days', 150.00, NULL, false, false),
(22, NOW() - INTERVAL '10 days', NOW(), 2, 2, NULL, 'Replace patio furniture', 'Sun-damaged furniture needs replacement', 10, 1, 4, 1, CURRENT_DATE + INTERVAL '60 days', 1200.00, NULL, false, false),
(23, NOW() - INTERVAL '180 days', NOW(), 2, 2, 2, 'Install security cameras', 'Add 4 outdoor security cameras', 7, 2, 3, 1, CURRENT_DATE - INTERVAL '150 days', 800.00, NULL, false, false),
-- ===== RESIDENCE 3 (Investment Duplex) - 5 tasks =====
(24, NOW() - INTERVAL '25 days', NOW(), 3, 2, NULL, 'Unit A - Repair drywall', 'Patch and paint drywall damage from tenant', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', 400.00, NULL, false, false),
(25, NOW() - INTERVAL '10 days', NOW(), 3, 2, NULL, 'Unit B - Replace dishwasher', 'Dishwasher not draining properly', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '3 days', 650.00, NULL, false, false),
(26, NOW() - INTERVAL '45 days', NOW(), 3, 2, 2, 'Annual fire inspection', 'Required annual fire safety inspection', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '30 days', 150.00, NULL, false, false),
(27, NOW() - INTERVAL '5 days', NOW(), 3, 2, NULL, 'Replace hallway carpet', 'Common area carpet showing wear', 6, 1, 1, 1, CURRENT_DATE + INTERVAL '45 days', 1500.00, NULL, false, false),
(28, NOW() - INTERVAL '200 days', NOW(), 3, 2, NULL, 'Roof inspection', 'Annual roof inspection', 6, 2, 3, 8, CURRENT_DATE - INTERVAL '170 days', 250.00, NULL, false, true),
-- ===== RESIDENCE 4 (Jane's Downtown Loft) - 7 tasks =====
(29, NOW() - INTERVAL '15 days', NOW(), 4, 3, 3, 'Fix garbage disposal', 'Disposal is jammed and making noise', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '2 days', 150.00, 8, false, false),
(30, NOW() - INTERVAL '30 days', NOW(), 4, 3, NULL, 'Clean dryer vent', 'Annual dryer vent cleaning for fire safety', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '30 days', 125.00, NULL, false, false),
(31, NOW() - INTERVAL '60 days', NOW(), 4, 3, 3, 'Touch up paint', 'Touch up scuff marks on walls', 6, 1, 3, 1, CURRENT_DATE - INTERVAL '45 days', 0.00, NULL, false, false),
(32, NOW() - INTERVAL '7 days', NOW(), 4, 3, NULL, 'Replace refrigerator water filter', 'Bi-annual filter replacement', 4, 1, 1, 7, CURRENT_DATE + INTERVAL '14 days', 50.00, 9, false, false),
(33, NOW() - INTERVAL '90 days', NOW(), 4, 3, NULL, 'Deep clean oven', 'Professional oven cleaning', 8, 1, 3, 8, CURRENT_DATE - INTERVAL '60 days', 100.00, NULL, false, false),
(34, NOW() - INTERVAL '120 days', NOW(), 4, 3, 3, 'Install smart thermostat', 'Replace old thermostat with Nest', 3, 2, 3, 1, CURRENT_DATE - INTERVAL '100 days', 250.00, NULL, false, false),
(35, NOW() - INTERVAL '3 days', NOW(), 4, 3, NULL, 'Repair window blinds', 'Bedroom blinds cord is broken', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', 75.00, NULL, false, false),
-- ===== RESIDENCE 5 (Bob's Mountain Condo) - 6 tasks =====
(36, NOW() - INTERVAL '20 days', NOW(), 5, 4, 4, 'Winterize pipes', 'Prepare plumbing for winter freeze', 1, 3, 3, 8, CURRENT_DATE - INTERVAL '10 days', 200.00, NULL, false, false),
(37, NOW() - INTERVAL '40 days', NOW(), 5, 4, NULL, 'Check roof for snow damage', 'Inspect after heavy snowfall', 6, 3, 3, 1, CURRENT_DATE - INTERVAL '35 days', 0.00, 10, false, false),
(38, NOW() - INTERVAL '10 days', NOW(), 5, 4, 12, 'Tune ski equipment storage', 'Organize ski room and check equipment', 10, 1, 2, 8, CURRENT_DATE + INTERVAL '7 days', 0.00, NULL, false, false),
(39, NOW() - INTERVAL '5 days', NOW(), 5, 4, NULL, 'Replace entry door weatherstrip', 'Cold air leaking around front door', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', 100.00, NULL, false, false),
(40, NOW() - INTERVAL '90 days', NOW(), 5, 4, 4, 'HOA deck staining', 'Required deck maintenance per HOA', 5, 2, 3, 8, CURRENT_DATE - INTERVAL '60 days', 500.00, NULL, false, false),
(41, NOW() - INTERVAL '180 days', NOW(), 5, 4, NULL, 'Fireplace inspection', 'Annual chimney and fireplace check', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '150 days', 175.00, NULL, false, false),
-- ===== RESIDENCE 8 (Alice's Craftsman) - 6 tasks =====
(42, NOW() - INTERVAL '25 days', NOW(), 8, 5, 5, 'Refinish hardwood floors', 'Sand and refinish original hardwood in living room', 6, 2, 2, 1, CURRENT_DATE + INTERVAL '30 days', 2000.00, 13, false, false),
(43, NOW() - INTERVAL '50 days', NOW(), 8, 5, 9, 'Repair original windows', 'Restore and weatherproof historic windows', 6, 2, 3, 1, CURRENT_DATE - INTERVAL '20 days', 1500.00, 13, false, false),
(44, NOW() - INTERVAL '15 days', NOW(), 8, 5, NULL, 'Update knob and tube wiring', 'Replace section of old wiring in attic', 2, 4, 1, 1, CURRENT_DATE + INTERVAL '14 days', 3000.00, NULL, false, false),
(45, NOW() - INTERVAL '30 days', NOW(), 8, 5, 5, 'Garden maintenance', 'Weekly garden care and weeding', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '7 days', 75.00, NULL, false, false),
(46, NOW() - INTERVAL '7 days', NOW(), 8, 5, NULL, 'Fix porch swing', 'Chains need tightening and wood needs oiling', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', 50.00, 13, false, false),
(47, NOW() - INTERVAL '60 days', NOW(), 8, 5, 5, 'Clean rain gutters', 'Clear leaves from craftsman-style gutters', 5, 2, 3, 7, CURRENT_DATE - INTERVAL '30 days', 200.00, NULL, false, false),
-- ===== RESIDENCE 11 (Edward's Fixer Upper) - 5 tasks =====
(48, NOW() - INTERVAL '14 days', NOW(), 11, 8, 8, 'Demo bathroom tile', 'Remove old tile in master bathroom', 6, 3, 2, 1, CURRENT_DATE + INTERVAL '7 days', 500.00, 15, false, false),
(49, NOW() - INTERVAL '10 days', NOW(), 11, 8, NULL, 'Install new cabinets', 'Kitchen cabinet installation', 6, 3, 1, 1, CURRENT_DATE + INTERVAL '21 days', 5000.00, 16, false, false),
(50, NOW() - INTERVAL '7 days', NOW(), 11, 8, 8, 'Run new electrical', 'Additional outlets for kitchen', 2, 3, 2, 1, CURRENT_DATE + INTERVAL '14 days', 1200.00, NULL, false, false),
(51, NOW() - INTERVAL '3 days', NOW(), 11, 8, NULL, 'Plumbing rough-in', 'Rough plumbing for bathroom remodel', 1, 3, 1, 1, CURRENT_DATE + INTERVAL '10 days', 2500.00, NULL, false, false),
(52, NOW() - INTERVAL '1 day', NOW(), 11, 8, 8, 'Order new appliances', 'Select and order kitchen appliances', 4, 2, 2, 1, CURRENT_DATE + INTERVAL '5 days', 4000.00, NULL, false, false),
-- ===== RESIDENCE 12 (Fiona's Studio) - 4 tasks =====
(53, NOW() - INTERVAL '7 days', NOW(), 12, 9, 9, 'Setup smart lights', 'Install Philips Hue throughout studio', 2, 1, 3, 1, CURRENT_DATE - INTERVAL '3 days', 400.00, 17, false, false),
(54, NOW() - INTERVAL '5 days', NOW(), 12, 9, NULL, 'Mount TV on wall', 'Install TV mount and hide cables', 2, 1, 1, 1, CURRENT_DATE + INTERVAL '10 days', 200.00, 17, false, false),
(55, NOW() - INTERVAL '3 days', NOW(), 12, 9, 9, 'Organize closet', 'Install closet organization system', 10, 1, 2, 1, CURRENT_DATE + INTERVAL '7 days', 300.00, NULL, false, false),
(56, NOW() - INTERVAL '1 day', NOW(), 12, 9, NULL, 'Replace faucet aerator', 'Low water pressure in bathroom sink', 1, 1, 1, 1, CURRENT_DATE + INTERVAL '14 days', 15.00, NULL, false, false),
-- ===== RESIDENCE 13 (George's Townhome) - 5 tasks =====
(57, NOW() - INTERVAL '30 days', NOW(), 13, 12, 12, 'Service pool equipment', 'Quarterly pool pump and filter service', 10, 2, 3, 6, CURRENT_DATE - INTERVAL '15 days', 175.00, 19, false, false),
(58, NOW() - INTERVAL '20 days', NOW(), 13, 12, NULL, 'HVAC maintenance', 'Pre-summer AC tune-up', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '30 days', 200.00, 18, false, false),
(59, NOW() - INTERVAL '10 days', NOW(), 13, 12, 12, 'Trim desert landscaping', 'Prune cacti and desert plants', 5, 1, 2, 6, CURRENT_DATE + INTERVAL '5 days', 100.00, NULL, false, false),
(60, NOW() - INTERVAL '45 days', NOW(), 13, 12, 12, 'Check irrigation system', 'Test all drip irrigation zones', 5, 2, 3, 5, CURRENT_DATE - INTERVAL '30 days', 50.00, NULL, false, false),
(61, NOW() - INTERVAL '5 days', NOW(), 13, 12, NULL, 'Replace garage door opener', 'Old opener failing', 4, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', 400.00, NULL, false, false),
-- Cancelled task
(62, NOW() - INTERVAL '60 days', NOW(), 1, 2, NULL, 'Build treehouse', 'Cancelled - tree not suitable', 6, 1, 5, 1, CURRENT_DATE - INTERVAL '30 days', 2000.00, NULL, true, false)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
-- =====================================================
-- TASK COMPLETIONS WITH IMAGES
-- =====================================================
INSERT INTO task_taskcompletion (id, created_at, updated_at, task_id, completed_by_id, completed_at, notes, actual_cost, rating)
VALUES VALUES
(1, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days', 4, 3, NOW() - INTERVAL '2 days', 'Lawn looks great!', NULL) -- Completed tasks from above with various ratings and costs
ON CONFLICT (id) DO UPDATE SET (1, NOW() - INTERVAL '45 days', NOW(), 2, 3, NOW() - INTERVAL '45 days', 'Used drain snake and cleared hair clog. Recommend drain cover.', 75.00, 5),
notes = EXCLUDED.notes, (2, NOW() - INTERVAL '60 days', NOW(), 5, 2, NOW() - INTERVAL '60 days', 'All 6 detectors updated with fresh 9V batteries.', 25.00, 4),
updated_at = NOW(); (3, NOW() - INTERVAL '15 days', NOW(), 7, 2, NOW() - INTERVAL '15 days', 'Replaced with MERV 13 filters. System running great.', 55.00, 5),
(4, NOW() - INTERVAL '2 days', NOW(), 9, 3, NOW() - INTERVAL '2 days', 'Lawn looks perfect. Edged along sidewalks and driveway.', 50.00, 5),
(5, NOW() - INTERVAL '70 days', NOW(), 11, 5, NOW() - INTERVAL '70 days', 'Removed significant leaf buildup. Found minor gutter damage on north side - will need repair next year.', 200.00, 4),
(6, NOW() - INTERVAL '150 days', NOW(), 13, 2, NOW() - INTERVAL '150 days', 'All extinguishers passed inspection. Kitchen one expires next year.', 0.00, 5),
(7, NOW() - INTERVAL '25 days', NOW(), 14, 3, NOW() - INTERVAL '25 days', 'Steam cleaned all carpets. Looks like new! Recommend annual cleaning.', 375.00, 5),
(8, NOW() - INTERVAL '6 days', NOW(), 16, 2, NOW() - INTERVAL '6 days', 'pH balanced. Added chlorine. Water crystal clear.', 45.00, 4),
(9, NOW() - INTERVAL '40 days', NOW(), 19, 2, NOW() - INTERVAL '40 days', 'Fixed low pressure issue - was a clogged showerhead. Replaced with rain showerhead.', 150.00, 5),
(10, NOW() - INTERVAL '60 days', NOW(), 21, 2, NOW() - INTERVAL '60 days', 'Interior and exterior treatment completed. No signs of pests.', 150.00, 4),
(11, NOW() - INTERVAL '150 days', NOW(), 23, 2, NOW() - INTERVAL '150 days', 'Installed 4 Arlo cameras. App configured and working great. Can view remotely.', 950.00, 5),
(12, NOW() - INTERVAL '30 days', NOW(), 26, 2, NOW() - INTERVAL '30 days', 'Passed inspection. All smoke detectors and fire extinguishers compliant.', 150.00, 5),
(13, NOW() - INTERVAL '170 days', NOW(), 28, 2, NOW() - INTERVAL '170 days', 'No damage found. Roof in good condition for another 5+ years.', 200.00, 4),
(14, NOW() - INTERVAL '45 days', NOW(), 31, 3, NOW() - INTERVAL '45 days', 'Touched up all scuffs in hallway and bedroom. Used matching Benjamin Moore paint.', 0.00, 5),
(15, NOW() - INTERVAL '60 days', NOW(), 33, 3, NOW() - INTERVAL '60 days', 'Oven sparkling clean. Removed years of baked-on grease.', 100.00, 4),
(16, NOW() - INTERVAL '100 days', NOW(), 34, 3, NOW() - INTERVAL '100 days', 'Nest installed and configured. Already seeing energy savings!', 275.00, 5),
(17, NOW() - INTERVAL '10 days', NOW(), 36, 4, NOW() - INTERVAL '10 days', 'All pipes insulated. Outdoor spigots covered. Ready for winter.', 225.00, 5),
(18, NOW() - INTERVAL '35 days', NOW(), 37, 4, NOW() - INTERVAL '35 days', 'No damage from snow. Cleared some accumulated ice from valleys.', 0.00, 4),
(19, NOW() - INTERVAL '60 days', NOW(), 40, 4, NOW() - INTERVAL '60 days', 'Applied two coats of deck stain. Looks beautiful. HOA approved.', 550.00, 5),
(20, NOW() - INTERVAL '150 days', NOW(), 41, 4, NOW() - INTERVAL '150 days', 'Chimney swept. Damper working properly. Ready for ski season!', 175.00, 5),
(21, NOW() - INTERVAL '20 days', NOW(), 43, 9, NOW() - INTERVAL '20 days', 'Beautiful restoration work. Windows operate smoothly and seal properly now.', 1650.00, 5),
(22, NOW() - INTERVAL '7 days', NOW(), 45, 5, NOW() - INTERVAL '7 days', 'Weeded all beds. Pruned roses. Garden looking great for spring.', 75.00, 4),
(23, NOW() - INTERVAL '30 days', NOW(), 47, 5, NOW() - INTERVAL '30 days', 'Cleared all gutters and downspouts. Historic copper gutters in good shape.', 225.00, 5),
(24, NOW() - INTERVAL '3 days', NOW(), 53, 9, NOW() - INTERVAL '3 days', 'All 12 Hue bulbs installed. Set up scenes for work, relax, and party modes!', 420.00, 5),
(25, NOW() - INTERVAL '15 days', NOW(), 57, 12, NOW() - INTERVAL '15 days', 'Pump running great. Filter cleaned. Water sparkling.', 175.00, 4),
(26, NOW() - INTERVAL '30 days', NOW(), 60, 12, NOW() - INTERVAL '30 days', 'All 8 zones tested. Replaced 3 emitters. System efficient.', 65.00, 5)
ON CONFLICT (id) DO UPDATE SET notes = EXCLUDED.notes, updated_at = NOW();
-- Test Documents (using Go/GORM schema) -- =====================================================
INSERT INTO task_document (id, created_at, updated_at, residence_id, created_by_id, title, description, document_type, file_url, file_name, purchase_date, expiry_date, purchase_price, vendor, serial_number, is_active) -- TASK COMPLETION IMAGES
-- =====================================================
INSERT INTO task_taskcompletionimage (id, created_at, updated_at, completion_id, image_url, caption)
VALUES VALUES
(1, NOW(), NOW(), 1, 2, 'HVAC Warranty', 'Warranty for central air system', 'warranty', '/uploads/docs/hvac_warranty.pdf', 'hvac_warranty.pdf', '2023-06-15', '2028-06-15', 5000.00, 'Cool Air HVAC', 'HVAC-2023-001', true), -- Completion 1 (Drain unclog) - 2 images
(2, NOW(), NOW(), 1, 2, 'Home Insurance Policy', 'Annual home insurance', 'insurance', '/uploads/docs/insurance.pdf', 'insurance.pdf', '2024-01-01', '2025-01-01', 1200.00, 'State Farm', NULL, true), (1, NOW() - INTERVAL '45 days', NOW(), 1, 'https://picsum.photos/seed/drain1/800/600', 'Before - slow draining sink'),
(3, NOW(), NOW(), 1, 2, 'Refrigerator Manual', 'User manual for kitchen fridge', 'manual', '/uploads/docs/fridge_manual.pdf', 'fridge_manual.pdf', '2022-03-20', NULL, 1500.00, 'Best Buy', 'LG-RF-2022', true), (2, NOW() - INTERVAL '45 days', NOW(), 1, 'https://picsum.photos/seed/drain2/800/600', 'After - drain cleared and flowing'),
(4, NOW(), NOW(), 3, 3, 'Lease Agreement', 'Apartment lease contract', 'contract', '/uploads/docs/lease.pdf', 'lease.pdf', '2024-01-01', '2025-01-01', NULL, 'Downtown Properties LLC', NULL, true)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
updated_at = NOW();
-- Test Notifications -- Completion 3 (HVAC filters) - 3 images
INSERT INTO notifications_notification (id, created_at, updated_at, user_id, notification_type, title, body, task_id, sent, read) (3, NOW() - INTERVAL '15 days', NOW(), 3, 'https://picsum.photos/seed/filter1/800/600', 'Old dirty filter'),
(4, NOW() - INTERVAL '15 days', NOW(), 3, 'https://picsum.photos/seed/filter2/800/600', 'New MERV 13 filter installed'),
(5, NOW() - INTERVAL '15 days', NOW(), 3, 'https://picsum.photos/seed/filter3/800/600', 'HVAC system running'),
-- Completion 4 (Lawn mowing) - 4 images
(6, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn1/800/600', 'Front yard before'),
(7, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn2/800/600', 'Front yard after'),
(8, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn3/800/600', 'Back yard completed'),
(9, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn4/800/600', 'Clean edging along driveway'),
-- Completion 5 (Gutter cleaning) - 2 images
(10, NOW() - INTERVAL '70 days', NOW(), 5, 'https://picsum.photos/seed/gutter1/800/600', 'Leaves removed from gutters'),
(11, NOW() - INTERVAL '70 days', NOW(), 5, 'https://picsum.photos/seed/gutter2/800/600', 'Minor damage found - north side'),
-- Completion 7 (Carpet cleaning) - 3 images
(12, NOW() - INTERVAL '25 days', NOW(), 7, 'https://picsum.photos/seed/carpet1/800/600', 'Living room before'),
(13, NOW() - INTERVAL '25 days', NOW(), 7, 'https://picsum.photos/seed/carpet2/800/600', 'Living room after'),
(14, NOW() - INTERVAL '25 days', NOW(), 7, 'https://picsum.photos/seed/carpet3/800/600', 'Bedroom carpet fresh'),
-- Completion 9 (Outdoor shower) - 2 images
(15, NOW() - INTERVAL '40 days', NOW(), 9, 'https://picsum.photos/seed/shower1/800/600', 'New rain showerhead installed'),
(16, NOW() - INTERVAL '40 days', NOW(), 9, 'https://picsum.photos/seed/shower2/800/600', 'Full water pressure restored'),
-- Completion 11 (Security cameras) - 4 images
(17, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam1/800/600', 'Front door camera'),
(18, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam2/800/600', 'Driveway camera'),
(19, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam3/800/600', 'Backyard camera'),
(20, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam4/800/600', 'Pool area camera'),
-- Completion 16 (Smart thermostat) - 2 images
(21, NOW() - INTERVAL '100 days', NOW(), 16, 'https://picsum.photos/seed/nest1/800/600', 'Old thermostat removed'),
(22, NOW() - INTERVAL '100 days', NOW(), 16, 'https://picsum.photos/seed/nest2/800/600', 'New Nest installed and configured'),
-- Completion 17 (Winterize pipes) - 3 images
(23, NOW() - INTERVAL '10 days', NOW(), 17, 'https://picsum.photos/seed/winter1/800/600', 'Pipe insulation applied'),
(24, NOW() - INTERVAL '10 days', NOW(), 17, 'https://picsum.photos/seed/winter2/800/600', 'Outdoor spigot cover'),
(25, NOW() - INTERVAL '10 days', NOW(), 17, 'https://picsum.photos/seed/winter3/800/600', 'Heat tape on exposed pipes'),
-- Completion 19 (Deck staining) - 4 images
(26, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck1/800/600', 'Deck before staining'),
(27, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck2/800/600', 'First coat applied'),
(28, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck3/800/600', 'Second coat complete'),
(29, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck4/800/600', 'Final result - beautiful!'),
-- Completion 21 (Window restoration) - 3 images
(30, NOW() - INTERVAL '20 days', NOW(), 21, 'https://picsum.photos/seed/window1/800/600', 'Original window before'),
(31, NOW() - INTERVAL '20 days', NOW(), 21, 'https://picsum.photos/seed/window2/800/600', 'Restoration in progress'),
(32, NOW() - INTERVAL '20 days', NOW(), 21, 'https://picsum.photos/seed/window3/800/600', 'Restored window - good as new'),
-- Completion 24 (Smart lights) - 2 images
(33, NOW() - INTERVAL '3 days', NOW(), 24, 'https://picsum.photos/seed/hue1/800/600', 'Hue lights in work mode'),
(34, NOW() - INTERVAL '3 days', NOW(), 24, 'https://picsum.photos/seed/hue2/800/600', 'Hue lights in relax mode')
ON CONFLICT (id) DO UPDATE SET image_url = EXCLUDED.image_url, caption = EXCLUDED.caption;
-- =====================================================
-- DOCUMENTS WITH IMAGES (all document types)
-- Document types: general, warranty, receipt, contract, insurance, manual
-- =====================================================
INSERT INTO task_document (id, created_at, updated_at, residence_id, created_by_id, task_id, title, description, document_type, file_url, file_name, file_size, mime_type, purchase_date, expiry_date, purchase_price, vendor, serial_number, model_number, is_active)
VALUES VALUES
(1, NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day', 2, 'task_due_soon', 'Task Due Soon', 'Fix leaky faucet is due in 7 days', 1, true, false), -- ===== RESIDENCE 1 - Warranties and documents =====
(2, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days', 2, 'task_completed', 'Task Completed', 'Mow lawn has been marked as completed', 4, true, true), (1, NOW() - INTERVAL '6 months', NOW(), 1, 2, NULL, 'HVAC System Warranty', '5-year warranty on Carrier central air system', 'warranty', 'https://example.com/docs/hvac_warranty.pdf', 'hvac_warranty.pdf', 245000, 'application/pdf', '2023-06-15', '2028-06-15', 5500.00, 'Arctic Comfort HVAC', 'CARRIER-2023-45892', '24ACC636A003', true),
(3, NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days', 3, 'task_assigned', 'Task Assigned', 'You have been assigned to: Mow lawn', 4, true, true) (2, NOW() - INTERVAL '5 months', NOW(), 1, 2, NULL, 'Water Heater Warranty', '6-year warranty on Rheem water heater', 'warranty', 'https://example.com/docs/water_heater.pdf', 'water_heater_warranty.pdf', 189000, 'application/pdf', '2022-03-10', '2028-03-10', 1200.00, 'Home Depot', 'RHEEM-22-78456', 'PROG50-38N RH67', true),
ON CONFLICT (id) DO UPDATE SET (3, NOW() - INTERVAL '4 months', NOW(), 1, 2, NULL, 'Refrigerator Warranty', '2-year manufacturer warranty on LG refrigerator', 'warranty', 'https://example.com/docs/fridge_warranty.pdf', 'lg_fridge_warranty.pdf', 156000, 'application/pdf', '2024-01-20', '2026-01-20', 2800.00, 'Best Buy', 'LG-RF-2024-11234', 'LRMVS3006S', true),
title = EXCLUDED.title, (4, NOW() - INTERVAL '3 months', NOW(), 1, 2, NULL, 'Dishwasher Warranty', '1-year warranty - expiring soon!', 'warranty', 'https://example.com/docs/dishwasher.pdf', 'bosch_dishwasher.pdf', 134000, 'application/pdf', '2023-08-05', '2024-08-05', 950.00, 'Lowes', 'BOSCH-DW-89012', 'SHPM88Z75N', true),
body = EXCLUDED.body, (5, NOW() - INTERVAL '1 year', NOW(), 1, 2, NULL, 'Home Insurance Policy', 'Annual homeowners insurance - State Farm', 'insurance', 'https://example.com/docs/insurance_2024.pdf', 'home_insurance_2024.pdf', 890000, 'application/pdf', '2024-01-01', '2025-01-01', 1450.00, 'State Farm', NULL, NULL, true),
updated_at = NOW(); (6, NOW() - INTERVAL '6 months', NOW(), 1, 2, NULL, 'Refrigerator User Manual', 'LG French Door Refrigerator manual', 'manual', 'https://example.com/docs/lg_manual.pdf', 'lg_fridge_manual.pdf', 4500000, 'application/pdf', NULL, NULL, NULL, 'LG Electronics', NULL, 'LRMVS3006S', true),
(7, NOW() - INTERVAL '2 months', NOW(), 1, 2, 7, 'HVAC Filter Receipt', 'Receipt for MERV 13 filters', 'receipt', 'https://example.com/docs/filter_receipt.pdf', 'hvac_filters_receipt.pdf', 45000, 'application/pdf', '2024-10-15', NULL, 55.00, 'Amazon', NULL, NULL, true),
(8, NOW() - INTERVAL '3 years', NOW(), 1, 2, NULL, 'Roof Inspection Report', 'Annual roof inspection certificate', 'general', 'https://example.com/docs/roof_inspection.pdf', 'roof_inspection_2024.pdf', 320000, 'application/pdf', '2024-04-20', NULL, 250.00, 'Peak Roofing', NULL, NULL, true),
-- Notification Preferences -- ===== RESIDENCE 2 - Beach house documents =====
(9, NOW() - INTERVAL '4 months', NOW(), 2, 2, NULL, 'Hurricane Shutters Warranty', '10-year warranty on accordion shutters', 'warranty', 'https://example.com/docs/shutters_warranty.pdf', 'hurricane_shutters.pdf', 278000, 'application/pdf', '2023-05-01', '2033-05-01', 8500.00, 'Storm Ready Inc', 'STORM-2023-456', 'ACC-200', true),
(10, NOW() - INTERVAL '3 months', NOW(), 2, 2, NULL, 'Pool Equipment Warranty', '3-year warranty on pool pump and filter', 'warranty', 'https://example.com/docs/pool_warranty.pdf', 'pool_equipment_warranty.pdf', 167000, 'application/pdf', '2024-02-15', '2027-02-15', 2200.00, 'Pool Supply World', 'PSW-PUMP-78123', 'Pentair 342001', true),
(11, NOW() - INTERVAL '6 months', NOW(), 2, 2, 23, 'Security Camera Receipt', 'Arlo Pro 4 cameras purchase receipt', 'receipt', 'https://example.com/docs/camera_receipt.pdf', 'arlo_cameras_receipt.pdf', 89000, 'application/pdf', '2024-07-10', NULL, 950.00, 'Amazon', NULL, 'VMC4050P', true),
(12, NOW() - INTERVAL '1 year', NOW(), 2, 2, NULL, 'Flood Insurance Policy', 'FEMA flood insurance for coastal property', 'insurance', 'https://example.com/docs/flood_insurance.pdf', 'flood_insurance_2024.pdf', 456000, 'application/pdf', '2024-06-01', '2025-06-01', 2800.00, 'FEMA NFIP', NULL, NULL, true),
-- ===== RESIDENCE 3 - Investment duplex =====
(13, NOW() - INTERVAL '5 months', NOW(), 3, 2, NULL, 'Rental Property Insurance', 'Landlord insurance policy', 'insurance', 'https://example.com/docs/rental_insurance.pdf', 'landlord_insurance.pdf', 567000, 'application/pdf', '2024-03-01', '2025-03-01', 1800.00, 'Allstate', NULL, NULL, true),
(14, NOW() - INTERVAL '4 months', NOW(), 3, 2, NULL, 'Unit A Lease Agreement', 'Current tenant lease - Unit A', 'contract', 'https://example.com/docs/lease_a.pdf', 'unit_a_lease.pdf', 234000, 'application/pdf', '2024-04-01', '2025-03-31', NULL, NULL, NULL, NULL, true),
(15, NOW() - INTERVAL '3 months', NOW(), 3, 2, NULL, 'Unit B Lease Agreement', 'Current tenant lease - Unit B', 'contract', 'https://example.com/docs/lease_b.pdf', 'unit_b_lease.pdf', 234000, 'application/pdf', '2024-06-01', '2025-05-31', NULL, NULL, NULL, NULL, true),
(16, NOW() - INTERVAL '6 months', NOW(), 3, 2, 26, 'Fire Inspection Certificate', 'Annual fire safety compliance', 'general', 'https://example.com/docs/fire_cert.pdf', 'fire_inspection_2024.pdf', 123000, 'application/pdf', '2024-09-15', '2025-09-15', 150.00, 'City Fire Department', NULL, NULL, true),
-- ===== RESIDENCE 4 - Jane's loft =====
(17, NOW() - INTERVAL '5 months', NOW(), 4, 3, NULL, 'Condo HOA Agreement', 'HOA rules and regulations', 'contract', 'https://example.com/docs/hoa_agreement.pdf', 'hoa_agreement.pdf', 890000, 'application/pdf', '2024-01-15', NULL, NULL, 'Downtown Towers HOA', NULL, NULL, true),
(18, NOW() - INTERVAL '4 months', NOW(), 4, 3, NULL, 'Nest Thermostat Warranty', '2-year Google warranty', 'warranty', 'https://example.com/docs/nest_warranty.pdf', 'nest_warranty.pdf', 145000, 'application/pdf', '2024-08-01', '2026-08-01', 275.00, 'Google Store', 'NEST-2024-99887', 'T3007ES', true),
(19, NOW() - INTERVAL '3 months', NOW(), 4, 3, NULL, 'Renters Insurance', 'Personal property coverage', 'insurance', 'https://example.com/docs/renters.pdf', 'renters_insurance.pdf', 234000, 'application/pdf', '2024-07-01', '2025-07-01', 240.00, 'Lemonade', NULL, NULL, true),
(20, NOW() - INTERVAL '2 months', NOW(), 4, 3, 29, 'Garbage Disposal Manual', 'InSinkErator installation guide', 'manual', 'https://example.com/docs/disposal_manual.pdf', 'disposal_manual.pdf', 2300000, 'application/pdf', NULL, NULL, NULL, 'InSinkErator', NULL, 'Evolution Excel', true),
-- ===== RESIDENCE 5 - Bob's condo =====
(21, NOW() - INTERVAL '4 months', NOW(), 5, 4, NULL, 'Fireplace Warranty', 'Gas fireplace 5-year warranty', 'warranty', 'https://example.com/docs/fireplace.pdf', 'fireplace_warranty.pdf', 189000, 'application/pdf', '2022-11-10', '2027-11-10', 3500.00, 'Heat & Glo', 'HG-2022-45678', 'SL-750TRS-IPI', true),
(22, NOW() - INTERVAL '3 months', NOW(), 5, 4, NULL, 'Ski Condo HOA Docs', 'Mountain Village HOA documents', 'contract', 'https://example.com/docs/ski_hoa.pdf', 'mountain_village_hoa.pdf', 1200000, 'application/pdf', '2024-01-01', NULL, NULL, 'Mountain Village HOA', NULL, NULL, true),
(23, NOW() - INTERVAL '5 months', NOW(), 5, 4, 40, 'Deck Stain Receipt', 'Thompson''s WaterSeal purchase', 'receipt', 'https://example.com/docs/stain_receipt.pdf', 'deck_stain_receipt.pdf', 67000, 'application/pdf', '2024-08-20', NULL, 125.00, 'Home Depot', NULL, NULL, true),
-- ===== RESIDENCE 8 - Alice's craftsman =====
(24, NOW() - INTERVAL '3 months', NOW(), 8, 5, NULL, 'Historic Home Certificate', 'National Register listing certificate', 'general', 'https://example.com/docs/historic_cert.pdf', 'historic_certificate.pdf', 456000, 'application/pdf', '1985-06-15', NULL, NULL, 'National Park Service', NULL, NULL, true),
(25, NOW() - INTERVAL '2 months', NOW(), 8, 5, 43, 'Window Restoration Invoice', 'Historic window restoration work', 'receipt', 'https://example.com/docs/window_invoice.pdf', 'window_restoration.pdf', 123000, 'application/pdf', '2024-10-01', NULL, 1650.00, 'Preservation Builders', NULL, NULL, true),
(26, NOW() - INTERVAL '4 months', NOW(), 8, 5, NULL, 'Vintage Stove Manual', '1920s O''Keefe & Merritt restoration guide', 'manual', 'https://example.com/docs/vintage_stove.pdf', 'okeefe_merritt_manual.pdf', 3400000, 'application/pdf', NULL, NULL, NULL, 'Antique Stove Hospital', NULL, 'Model 600', true),
-- ===== RESIDENCE 11 - Edward's fixer upper =====
(27, NOW() - INTERVAL '2 weeks', NOW(), 11, 8, NULL, 'Renovation Permit', 'City building permit for renovation', 'general', 'https://example.com/docs/permit.pdf', 'building_permit.pdf', 234000, 'application/pdf', '2024-11-01', '2025-11-01', 850.00, 'City of Seattle', 'PERMIT-2024-78901', NULL, true),
(28, NOW() - INTERVAL '1 week', NOW(), 11, 8, 49, 'Cabinet Quote', 'Custom kitchen cabinet estimate', 'general', 'https://example.com/docs/cabinet_quote.pdf', 'cabinet_quote.pdf', 178000, 'application/pdf', '2024-11-20', NULL, 5000.00, 'Woodworks Plus', NULL, NULL, true),
(29, NOW() - INTERVAL '5 days', NOW(), 11, 8, NULL, 'Contractor Agreement', 'Renovation contract with Demo Dave', 'contract', 'https://example.com/docs/contractor.pdf', 'contractor_agreement.pdf', 345000, 'application/pdf', '2024-11-15', '2025-03-15', 15000.00, 'Demolition & Renovation', NULL, NULL, true),
-- ===== RESIDENCE 12 - Fiona's studio =====
(30, NOW() - INTERVAL '1 week', NOW(), 12, 9, 53, 'Smart Lights Receipt', 'Philips Hue starter kit receipt', 'receipt', 'https://example.com/docs/hue_receipt.pdf', 'philips_hue_receipt.pdf', 89000, 'application/pdf', '2024-11-18', NULL, 420.00, 'Best Buy', NULL, 'Hue White and Color Ambiance', true),
(31, NOW() - INTERVAL '5 days', NOW(), 12, 9, NULL, 'Studio Lease', 'Apartment lease agreement', 'contract', 'https://example.com/docs/studio_lease.pdf', 'studio_lease.pdf', 456000, 'application/pdf', '2024-11-15', '2025-11-14', NULL, 'Arts District Properties', NULL, NULL, true),
-- ===== RESIDENCE 13 - George's townhome =====
(32, NOW() - INTERVAL '2 months', NOW(), 13, 12, NULL, 'Pool Heater Warranty', '3-year warranty on Jandy pool heater', 'warranty', 'https://example.com/docs/pool_heater.pdf', 'pool_heater_warranty.pdf', 198000, 'application/pdf', '2024-03-01', '2027-03-01', 1800.00, 'Pool Supply Arizona', 'JANDY-2024-34567', 'JXi400N', true),
(33, NOW() - INTERVAL '1 month', NOW(), 13, 12, NULL, 'HOA CC&Rs', 'Townhome community rules', 'contract', 'https://example.com/docs/townhome_ccr.pdf', 'ccr_documents.pdf', 2100000, 'application/pdf', NULL, NULL, NULL, 'Desert Palms HOA', NULL, NULL, true),
-- Expired warranty
(34, NOW() - INTERVAL '3 years', NOW(), 1, 2, NULL, 'Old Washer Warranty', 'Expired washing machine warranty', 'warranty', 'https://example.com/docs/old_washer.pdf', 'washer_warranty_expired.pdf', 145000, 'application/pdf', '2020-05-15', '2023-05-15', 800.00, 'Sears', 'WH-2020-11111', 'WTW5000DW', false)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
-- =====================================================
-- DOCUMENT IMAGES
-- =====================================================
INSERT INTO task_documentimage (id, created_at, updated_at, document_id, image_url, caption)
VALUES
-- HVAC System (doc 1)
(1, NOW(), NOW(), 1, 'https://picsum.photos/seed/hvac1/800/600', 'HVAC unit exterior'),
(2, NOW(), NOW(), 1, 'https://picsum.photos/seed/hvac2/800/600', 'Serial number plate'),
(3, NOW(), NOW(), 1, 'https://picsum.photos/seed/hvac3/800/600', 'Installation complete'),
-- Water Heater (doc 2)
(4, NOW(), NOW(), 2, 'https://picsum.photos/seed/waterheater1/800/600', 'Water heater installed'),
(5, NOW(), NOW(), 2, 'https://picsum.photos/seed/waterheater2/800/600', 'Model and serial info'),
-- Refrigerator (doc 3)
(6, NOW(), NOW(), 3, 'https://picsum.photos/seed/fridge1/800/600', 'LG French Door refrigerator'),
(7, NOW(), NOW(), 3, 'https://picsum.photos/seed/fridge2/800/600', 'Interior view'),
-- Hurricane Shutters (doc 9)
(8, NOW(), NOW(), 9, 'https://picsum.photos/seed/shutter1/800/600', 'Accordion shutters closed'),
(9, NOW(), NOW(), 9, 'https://picsum.photos/seed/shutter2/800/600', 'Shutters open position'),
(10, NOW(), NOW(), 9, 'https://picsum.photos/seed/shutter3/800/600', 'Window protection detail'),
-- Pool Equipment (doc 10)
(11, NOW(), NOW(), 10, 'https://picsum.photos/seed/pool1/800/600', 'Pool pump installation'),
(12, NOW(), NOW(), 10, 'https://picsum.photos/seed/pool2/800/600', 'Filter system'),
-- Security Cameras (doc 11)
(13, NOW(), NOW(), 11, 'https://picsum.photos/seed/arlo1/800/600', 'Arlo camera box'),
(14, NOW(), NOW(), 11, 'https://picsum.photos/seed/arlo2/800/600', 'Camera mounted on wall'),
-- Historic Certificate (doc 24)
(15, NOW(), NOW(), 24, 'https://picsum.photos/seed/historic1/800/600', 'Historic plaque on house'),
(16, NOW(), NOW(), 24, 'https://picsum.photos/seed/historic2/800/600', 'Original craftsman details'),
-- Window Restoration (doc 25)
(17, NOW(), NOW(), 25, 'https://picsum.photos/seed/restore1/800/600', 'Before restoration'),
(18, NOW(), NOW(), 25, 'https://picsum.photos/seed/restore2/800/600', 'After restoration'),
(19, NOW(), NOW(), 25, 'https://picsum.photos/seed/restore3/800/600', 'Detail work complete'),
-- Smart Lights (doc 30)
(20, NOW(), NOW(), 30, 'https://picsum.photos/seed/huelight1/800/600', 'Hue starter kit'),
(21, NOW(), NOW(), 30, 'https://picsum.photos/seed/huelight2/800/600', 'Bulbs installed'),
-- Pool Heater (doc 32)
(22, NOW(), NOW(), 32, 'https://picsum.photos/seed/poolheat1/800/600', 'Jandy pool heater'),
(23, NOW(), NOW(), 32, 'https://picsum.photos/seed/poolheat2/800/600', 'Control panel'),
-- Renovation Permit (doc 27)
(24, NOW(), NOW(), 27, 'https://picsum.photos/seed/permit1/800/600', 'Permit posted on site')
ON CONFLICT (id) DO UPDATE SET image_url = EXCLUDED.image_url, caption = EXCLUDED.caption;
-- =====================================================
-- NOTIFICATIONS (various types and states)
-- Types: task_due_soon, task_overdue, task_completed, task_assigned, residence_shared, warranty_expiring
-- =====================================================
INSERT INTO notifications_notification (id, created_at, updated_at, user_id, notification_type, title, body, task_id, sent, sent_at, read, read_at)
VALUES
-- John's notifications (user 2)
(1, NOW() - INTERVAL '1 hour', NOW(), 2, 'task_due_soon', 'Task Due Soon', 'Fix leaky kitchen faucet is due in 7 days', 1, true, NOW() - INTERVAL '1 hour', false, NULL),
(2, NOW() - INTERVAL '3 hours', NOW(), 2, 'task_due_soon', 'Task Due Soon', 'Install ceiling fan is due in 14 days', 4, true, NOW() - INTERVAL '3 hours', true, NOW() - INTERVAL '2 hours'),
(3, NOW() - INTERVAL '1 day', NOW(), 2, 'task_completed', 'Task Completed', 'Mow lawn has been marked as completed by Jane', 9, true, NOW() - INTERVAL '1 day', true, NOW() - INTERVAL '20 hours'),
(4, NOW() - INTERVAL '2 days', NOW(), 2, 'warranty_expiring', 'Warranty Expiring Soon', 'Your Dishwasher Warranty expires in 60 days', NULL, true, NOW() - INTERVAL '2 days', false, NULL),
(5, NOW() - INTERVAL '3 days', NOW(), 2, 'task_overdue', 'Task Overdue', 'Check pool chemicals is overdue by 3 days', 16, true, NOW() - INTERVAL '3 days', true, NOW() - INTERVAL '3 days'),
-- Jane's notifications (user 3)
(6, NOW() - INTERVAL '2 hours', NOW(), 3, 'task_assigned', 'New Task Assigned', 'You have been assigned to: Fix garbage disposal', 29, true, NOW() - INTERVAL '2 hours', false, NULL),
(7, NOW() - INTERVAL '1 day', NOW(), 3, 'residence_shared', 'Residence Access Granted', 'John Doe has shared Main Family Home with you', NULL, true, NOW() - INTERVAL '1 day', true, NOW() - INTERVAL '22 hours'),
(8, NOW() - INTERVAL '4 days', NOW(), 3, 'task_completed', 'Task Completed', 'Unclog bathroom drain has been completed', 2, true, NOW() - INTERVAL '4 days', true, NOW() - INTERVAL '4 days'),
-- Bob's notifications (user 4)
(9, NOW() - INTERVAL '30 minutes', NOW(), 4, 'task_due_soon', 'Task Due Soon', 'Replace entry door weatherstrip is due in 10 days', 39, true, NOW() - INTERVAL '30 minutes', false, NULL),
(10, NOW() - INTERVAL '6 hours', NOW(), 4, 'task_completed', 'Task Completed', 'Winterize pipes completed successfully', 36, true, NOW() - INTERVAL '6 hours', true, NOW() - INTERVAL '5 hours'),
(11, NOW() - INTERVAL '2 days', NOW(), 4, 'task_due_soon', 'Task Due Soon', 'Tune ski equipment storage is due soon', 38, true, NOW() - INTERVAL '2 days', false, NULL),
-- Alice's notifications (user 5)
(12, NOW() - INTERVAL '4 hours', NOW(), 5, 'task_assigned', 'New Task Assigned', 'You have been assigned to: Refinish hardwood floors', 42, true, NOW() - INTERVAL '4 hours', true, NOW() - INTERVAL '3 hours'),
(13, NOW() - INTERVAL '1 day', NOW(), 5, 'warranty_expiring', 'Warranty Alert', 'Check your document warranties for upcoming expirations', NULL, true, NOW() - INTERVAL '1 day', false, NULL),
-- Edward's notifications (user 8)
(14, NOW() - INTERVAL '2 hours', NOW(), 8, 'task_due_soon', 'Task Due Soon', 'Demo bathroom tile is due in 7 days', 48, true, NOW() - INTERVAL '2 hours', false, NULL),
(15, NOW() - INTERVAL '5 hours', NOW(), 8, 'task_assigned', 'New Task Assigned', 'You have been assigned to: Run new electrical', 50, true, NOW() - INTERVAL '5 hours', true, NOW() - INTERVAL '4 hours'),
-- Fiona's notifications (user 9)
(16, NOW() - INTERVAL '1 hour', NOW(), 9, 'task_completed', 'Task Completed', 'Setup smart lights has been completed', 53, true, NOW() - INTERVAL '1 hour', true, NOW() - INTERVAL '45 minutes'),
(17, NOW() - INTERVAL '3 days', NOW(), 9, 'residence_shared', 'Shared Access', 'Alice has shared Craftsman Bungalow with you', NULL, true, NOW() - INTERVAL '3 days', true, NOW() - INTERVAL '3 days'),
-- George's notifications (user 12)
(18, NOW() - INTERVAL '5 hours', NOW(), 12, 'task_due_soon', 'Task Due Soon', 'Trim desert landscaping is due in 5 days', 59, true, NOW() - INTERVAL '5 hours', false, NULL),
(19, NOW() - INTERVAL '1 day', NOW(), 12, 'task_completed', 'Task Completed', 'Service pool equipment completed', 57, true, NOW() - INTERVAL '1 day', true, NOW() - INTERVAL '20 hours'),
-- Pending notifications (not yet sent)
(20, NOW() - INTERVAL '10 minutes', NOW(), 2, 'task_overdue', 'Task Overdue', 'Fertilize lawn is now overdue', 12, false, NULL, false, NULL),
(21, NOW() - INTERVAL '5 minutes', NOW(), 5, 'task_due_soon', 'Task Due Soon', 'Fix porch swing is due in 21 days', 46, false, NULL, false, NULL)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
-- =====================================================
-- NOTIFICATION PREFERENCES
-- =====================================================
INSERT INTO notifications_notificationpreference (id, created_at, updated_at, user_id, task_due_soon, task_overdue, task_completed, task_assigned, residence_shared, warranty_expiring) INSERT INTO notifications_notificationpreference (id, created_at, updated_at, user_id, task_due_soon, task_overdue, task_completed, task_assigned, residence_shared, warranty_expiring)
VALUES VALUES
(1, NOW(), NOW(), 1, true, true, true, true, true, true), (1, NOW(), NOW(), 1, true, true, true, true, true, true),
(2, NOW(), NOW(), 2, true, true, true, true, true, true), (2, NOW(), NOW(), 2, true, true, true, true, true, true),
(3, NOW(), NOW(), 3, true, true, false, true, true, false), (3, NOW(), NOW(), 3, true, true, false, true, true, false),
(4, NOW(), NOW(), 4, false, false, false, false, false, false) (4, NOW(), NOW(), 4, true, true, true, true, true, true),
ON CONFLICT (id) DO UPDATE SET (5, NOW(), NOW(), 5, true, true, true, true, true, true),
task_due_soon = EXCLUDED.task_due_soon, (6, NOW(), NOW(), 6, false, false, false, false, false, false),
task_overdue = EXCLUDED.task_overdue, (7, NOW(), NOW(), 7, true, false, false, true, true, false),
updated_at = NOW(); (8, NOW(), NOW(), 8, true, true, true, true, false, true),
(9, NOW(), NOW(), 9, true, true, true, true, true, true),
(10, NOW(), NOW(), 10, false, false, false, false, false, false),
(11, NOW(), NOW(), 11, true, true, true, true, true, true),
(12, NOW(), NOW(), 12, true, true, true, false, true, true)
ON CONFLICT (id) DO UPDATE SET task_due_soon = EXCLUDED.task_due_soon, updated_at = NOW();
-- Reset sequences -- =====================================================
-- PUSH DEVICES (APNs and GCM)
-- =====================================================
INSERT INTO push_notifications_apnsdevice (id, user_id, name, device_id, registration_id, active, date_created)
VALUES
(1, 1, 'Admin iPhone 15 Pro', 'admin-device-001', 'apns-token-admin-001-xxxx', true, NOW() - INTERVAL '6 months'),
(2, 2, 'John iPhone 14', 'john-device-001', 'apns-token-john-001-xxxx', true, NOW() - INTERVAL '5 months'),
(3, 2, 'John iPad Pro', 'john-device-002', 'apns-token-john-002-xxxx', true, NOW() - INTERVAL '4 months'),
(4, 4, 'Bob iPhone 13', 'bob-device-001', 'apns-token-bob-001-xxxx', true, NOW() - INTERVAL '3 months'),
(5, 5, 'Alice iPhone 15', 'alice-device-001', 'apns-token-alice-001-xxxx', true, NOW() - INTERVAL '2 months'),
(6, 9, 'Fiona iPhone 14 Pro', 'fiona-device-001', 'apns-token-fiona-001-xxxx', true, NOW() - INTERVAL '1 month'),
(7, 10, 'Inactive iPhone', 'inactive-device-001', 'apns-token-inactive-xxxx', false, NOW() - INTERVAL '1 year')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, active = EXCLUDED.active;
INSERT INTO push_notifications_gcmdevice (id, user_id, name, device_id, registration_id, active, cloud_message_type, date_created)
VALUES
(1, 3, 'Jane Pixel 8', 'jane-device-001', 'gcm-token-jane-001-xxxx', true, 'FCM', NOW() - INTERVAL '5 months'),
(2, 6, 'Charlie Galaxy S24', 'charlie-device-001', 'gcm-token-charlie-001-xxxx', true, 'FCM', NOW() - INTERVAL '4 months'),
(3, 8, 'Edward Pixel 7', 'edward-device-001', 'gcm-token-edward-001-xxxx', true, 'FCM', NOW() - INTERVAL '3 months'),
(4, 12, 'George Galaxy S23', 'george-device-001', 'gcm-token-george-001-xxxx', true, 'FCM', NOW() - INTERVAL '2 months'),
(5, 7, 'Diana old Android', 'diana-device-001', 'gcm-token-diana-001-xxxx', false, 'FCM', NOW() - INTERVAL '1 year')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, active = EXCLUDED.active;
-- =====================================================
-- ADMIN USERS
-- =====================================================
INSERT INTO admin_users (id, created_at, updated_at, email, password, first_name, last_name, role, is_active, last_login)
VALUES
(1, NOW() - INTERVAL '1 year', NOW(), 'superadmin@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Super', 'Admin', 'super_admin', true, NOW() - INTERVAL '1 hour'),
(2, NOW() - INTERVAL '6 months', NOW(), 'admin@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Regular', 'Admin', 'admin', true, NOW() - INTERVAL '2 hours'),
(3, NOW() - INTERVAL '3 months', NOW(), 'support@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Support', 'Staff', 'admin', true, NOW() - INTERVAL '1 day'),
(4, NOW() - INTERVAL '1 month', NOW(), 'inactive.admin@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Former', 'Admin', 'admin', false, NOW() - INTERVAL '2 months')
ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role, is_active = EXCLUDED.is_active, updated_at = NOW();
-- =====================================================
-- RESET SEQUENCES
-- =====================================================
SELECT setval('auth_user_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM auth_user), false); SELECT setval('auth_user_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM auth_user), false);
SELECT setval('user_userprofile_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM user_userprofile), false); SELECT setval('user_userprofile_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM user_userprofile), false);
SELECT setval('subscription_usersubscription_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM subscription_usersubscription), false); SELECT setval('subscription_usersubscription_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM subscription_usersubscription), false);
@@ -155,12 +607,36 @@ SELECT setval('residence_residence_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FRO
SELECT setval('task_contractor_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_contractor), false); SELECT setval('task_contractor_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_contractor), false);
SELECT setval('task_task_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_task), false); SELECT setval('task_task_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_task), false);
SELECT setval('task_taskcompletion_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_taskcompletion), false); SELECT setval('task_taskcompletion_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_taskcompletion), false);
SELECT setval('task_taskcompletionimage_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_taskcompletionimage), false);
SELECT setval('task_document_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_document), false); SELECT setval('task_document_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_document), false);
SELECT setval('task_documentimage_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_documentimage), false);
SELECT setval('notifications_notification_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notification), false); SELECT setval('notifications_notification_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notification), false);
SELECT setval('notifications_notificationpreference_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notificationpreference), false); SELECT setval('notifications_notificationpreference_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notificationpreference), false);
SELECT setval('push_notifications_apnsdevice_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM push_notifications_apnsdevice), false);
SELECT setval('push_notifications_gcmdevice_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM push_notifications_gcmdevice), false);
SELECT setval('admin_users_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM admin_users), false);
-- =====================================================
-- TEST DATA SUMMARY
-- =====================================================
-- Users: 12 (including admin, staff, inactive)
-- User Profiles: 12
-- Subscriptions: 12 (mix of free/pro, ios/android)
-- Residences: 14 (all property types)
-- Shared Residences: 5 sharing relationships
-- Contractors: 20 (all specialties, various ratings)
-- Tasks: 62 (all categories, priorities, statuses, frequencies)
-- Task Completions: 26 (with ratings 4-5)
-- Task Completion Images: 34
-- Documents: 34 (all types: warranty, insurance, contract, manual, receipt, general)
-- Document Images: 24
-- Notifications: 21 (all types, sent/pending, read/unread)
-- Notification Preferences: 12
-- APNs Devices: 7 (iOS devices)
-- GCM Devices: 5 (Android devices)
-- Admin Users: 4
-- Test Login Credentials:
-- Regular users: password123
-- Admin panel: superadmin@mycrib.com / password123
-- Test Users (password: password123):
-- - admin (admin@example.com) - Admin, Pro tier
-- - john (john@example.com) - Pro tier, owns 2 residences
-- - jane (jane@example.com) - Free tier, owns 1 residence, shared access to residence 1
-- - bob (bob@example.com) - Free tier