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:
@@ -3,7 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil } from 'lucide-react';
|
||||
import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil, Star } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { completionsApi } from '@/lib/api';
|
||||
@@ -188,6 +188,26 @@ export default function CompletionDetailPage() {
|
||||
<span className="text-muted-foreground">Not recorded</span>
|
||||
)}
|
||||
</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 className="text-sm font-medium text-muted-foreground">Record Created</div>
|
||||
<div>{new Date(completion.created_at).toLocaleString()}</div>
|
||||
@@ -210,33 +230,40 @@ export default function CompletionDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photo */}
|
||||
{completion.photo_url && (
|
||||
{/* Photos */}
|
||||
{completion.images && completion.images.length > 0 && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Photo</CardTitle>
|
||||
<CardTitle>Completion Photos ({completion.images.length})</CardTitle>
|
||||
<CardDescription>Photo evidence of task completion</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="relative max-w-2xl mx-auto">
|
||||
<img
|
||||
src={completion.photo_url}
|
||||
alt="Completion photo"
|
||||
className="rounded-lg border object-contain w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<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-4 w-4" />
|
||||
Open full size in new tab
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{completion.images.map((image, index) => (
|
||||
<div key={image.id || index} className="space-y-2">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || `Completion photo ${index + 1}`}
|
||||
className="rounded-lg border object-contain w-full"
|
||||
/>
|
||||
</div>
|
||||
{image.caption && (
|
||||
<p className="text-sm text-muted-foreground text-center">{image.caption}</p>
|
||||
)}
|
||||
<div className="flex justify-center">
|
||||
<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-4 w-4" />
|
||||
Open full size
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -252,34 +252,43 @@ export default function CompletionsPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{completion.photo_url ? (
|
||||
{completion.images && completion.images.length > 0 ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ImageIcon className="h-4 w-4 mr-1" />
|
||||
View
|
||||
{completion.images.length} {completion.images.length === 1 ? 'Photo' : 'Photos'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Completion Photo</DialogTitle>
|
||||
<DialogTitle>Completion Photos ({completion.images.length})</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={completion.photo_url}
|
||||
alt="Completion photo"
|
||||
className="object-contain w-full h-full rounded-lg"
|
||||
/>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{completion.images.map((image, index) => (
|
||||
<div key={image.id || index} className="space-y-2">
|
||||
<div className="relative aspect-video">
|
||||
<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>
|
||||
<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>
|
||||
</Dialog>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil, ImageIcon, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { documentsApi } from '@/lib/api';
|
||||
@@ -16,12 +18,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export function DocumentDetailClient() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const documentId = Number(params.id);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
const { data: document, isLoading, error } = useQuery({
|
||||
queryKey: ['document', documentId],
|
||||
@@ -181,6 +188,68 @@ export function DocumentDetailClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
291
admin/src/app/(dashboard)/limitations/page.tsx
Normal file
291
admin/src/app/(dashboard)/limitations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
508
admin/src/app/(dashboard)/limitations/triggers/page.tsx
Normal file
508
admin/src/app/(dashboard)/limitations/triggers/page.tsx
Normal 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 "{trigger.title}" 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 "Add Trigger" 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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, TestTube, Shield } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Database, TestTube } from 'lucide-react';
|
||||
|
||||
import { settingsApi } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -28,24 +26,6 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settingsApi.get,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: settingsApi.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
toast.success('Settings updated');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update settings');
|
||||
},
|
||||
});
|
||||
|
||||
const seedLookupsMutation = useMutation({
|
||||
mutationFn: settingsApi.seedLookups,
|
||||
onSuccess: (data) => {
|
||||
@@ -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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
@@ -96,35 +56,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Subscription Limitations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Subscription Limitations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control whether tier-based limitations are enforced for users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="enable-limitations">Enable Limitations</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, free tier users will have restricted access to features
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-limitations"
|
||||
checked={settings?.enable_limitations ?? false}
|
||||
onCheckedChange={handleLimitationsToggle}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Seed Lookup Data */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
CheckCircle,
|
||||
BookOpen,
|
||||
UserCog,
|
||||
Shield,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
@@ -47,6 +50,11 @@ const menuItems = [
|
||||
{ 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 = [
|
||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
||||
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
||||
@@ -94,6 +102,27 @@ export function AppSidebar() {
|
||||
</SidebarGroupContent>
|
||||
</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>
|
||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
|
||||
@@ -392,6 +392,13 @@ export const authTokensApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Task Completion Image
|
||||
export interface TaskCompletionImage {
|
||||
id: number;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
// Task Completions Types
|
||||
export interface TaskCompletion {
|
||||
id: number;
|
||||
@@ -404,7 +411,8 @@ export interface TaskCompletion {
|
||||
completed_at: string;
|
||||
notes: string;
|
||||
actual_cost: string | null;
|
||||
photo_url: string;
|
||||
rating: number | null;
|
||||
images: TaskCompletionImage[];
|
||||
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;
|
||||
|
||||
@@ -265,6 +265,12 @@ export interface UpdateContractorRequest {
|
||||
}
|
||||
|
||||
// Document types
|
||||
export interface DocumentImage {
|
||||
id: number;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
residence_id: number;
|
||||
@@ -278,6 +284,7 @@ export interface Document {
|
||||
expiry_date?: string;
|
||||
purchase_date?: string;
|
||||
is_active: boolean;
|
||||
images: DocumentImage[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -308,6 +315,7 @@ export interface CreateDocumentRequest {
|
||||
serial_number?: string;
|
||||
model_number?: string;
|
||||
task_id?: number;
|
||||
image_urls?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateDocumentRequest {
|
||||
|
||||
@@ -152,21 +152,29 @@ type ContractorDetailResponse struct {
|
||||
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
|
||||
type DocumentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DocumentType string `json:"document_type"`
|
||||
FileName string `json:"file_name"`
|
||||
FileURL string `json:"file_url"`
|
||||
Vendor string `json:"vendor"`
|
||||
ExpiryDate *string `json:"expiry_date,omitempty"`
|
||||
PurchaseDate *string `json:"purchase_date,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DocumentType string `json:"document_type"`
|
||||
FileName string `json:"file_name"`
|
||||
FileURL string `json:"file_url"`
|
||||
Vendor string `json:"vendor"`
|
||||
ExpiryDate *string `json:"expiry_date,omitempty"`
|
||||
PurchaseDate *string `json:"purchase_date,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Images []DocumentImageResponse `json:"images"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// DocumentDetailResponse includes more details for single document view
|
||||
|
||||
@@ -22,20 +22,28 @@ func NewAdminCompletionHandler(db *gorm.DB) *AdminCompletionHandler {
|
||||
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
|
||||
type CompletionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
TaskTitle string `json:"task_title"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
CompletedByID uint `json:"completed_by_id"`
|
||||
CompletedBy string `json:"completed_by"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *string `json:"actual_cost"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
TaskTitle string `json:"task_title"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
CompletedByID uint `json:"completed_by_id"`
|
||||
CompletedBy string `json:"completed_by"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *string `json:"actual_cost"`
|
||||
Rating *int `json:"rating"`
|
||||
Images []CompletionImageResponse `json:"images"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CompletionFilters extends PaginationParams with completion-specific filters
|
||||
@@ -60,7 +68,8 @@ func (h *AdminCompletionHandler) List(c *gin.Context) {
|
||||
query := h.db.Model(&models.TaskCompletion{}).
|
||||
Preload("Task").
|
||||
Preload("Task.Residence").
|
||||
Preload("CompletedBy")
|
||||
Preload("CompletedBy").
|
||||
Preload("Images")
|
||||
|
||||
// Apply search
|
||||
if filters.Search != "" {
|
||||
@@ -125,7 +134,7 @@ func (h *AdminCompletionHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
|
||||
return
|
||||
@@ -229,7 +238,7 @@ func (h *AdminCompletionHandler) Update(c *gin.Context) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -241,7 +250,8 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
|
||||
CompletedByID: completion.CompletedByID,
|
||||
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
|
||||
Notes: completion.Notes,
|
||||
PhotoURL: completion.PhotoURL,
|
||||
Rating: completion.Rating,
|
||||
Images: make([]CompletionImageResponse, 0),
|
||||
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
@@ -262,5 +272,14 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
|
||||
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
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ func (h *AdminDocumentHandler) List(c *gin.Context) {
|
||||
|
||||
query := h.db.Model(&models.Document{}).
|
||||
Preload("Residence").
|
||||
Preload("CreatedBy")
|
||||
Preload("CreatedBy").
|
||||
Preload("Images")
|
||||
|
||||
// Apply search
|
||||
if filters.Search != "" {
|
||||
@@ -98,6 +99,7 @@ func (h *AdminDocumentHandler) Get(c *gin.Context) {
|
||||
Preload("Residence").
|
||||
Preload("CreatedBy").
|
||||
Preload("Task").
|
||||
Preload("Images").
|
||||
First(&document, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
@@ -166,7 +168,7 @@ func (h *AdminDocumentHandler) Update(c *gin.Context) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -236,7 +238,7 @@ func (h *AdminDocumentHandler) Create(c *gin.Context) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -296,6 +298,7 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
|
||||
FileURL: doc.FileURL,
|
||||
Vendor: doc.Vendor,
|
||||
IsActive: doc.IsActive,
|
||||
Images: make([]dto.DocumentImageResponse, 0),
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
480
internal/admin/handlers/limitations_handler.go
Normal file
480
internal/admin/handlers/limitations_handler.go
Normal 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"})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -125,17 +126,29 @@ func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
||||
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
|
||||
// This is needed because GORM/PostgreSQL prepared statements don't support multiple commands
|
||||
statements := splitSQLStatements(string(sqlContent))
|
||||
|
||||
for _, stmt := range statements {
|
||||
for i, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.db.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
// Use the raw sql.DB to avoid GORM's prepared statement handling
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ func Migrate() error {
|
||||
&models.Contractor{}, // Contractor before Task (Task references Contractor)
|
||||
&models.Task{},
|
||||
&models.TaskCompletion{},
|
||||
&models.TaskCompletionImage{}, // Multiple images per completion
|
||||
&models.Document{},
|
||||
&models.DocumentImage{}, // Multiple images per document
|
||||
|
||||
// Notification tables
|
||||
&models.Notification{},
|
||||
|
||||
@@ -25,6 +25,7 @@ type CreateDocumentRequest struct {
|
||||
SerialNumber string `json:"serial_number" binding:"max=100"`
|
||||
ModelNumber string `json:"model_number" binding:"max=100"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
||||
}
|
||||
|
||||
// UpdateDocumentRequest represents the request to update a document
|
||||
|
||||
@@ -88,5 +88,12 @@ type CreateTaskCompletionRequest struct {
|
||||
CompletedAt *time.Time `json:"completed_at"` // Defaults to now
|
||||
Notes string `json:"notes"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ type DocumentUserResponse struct {
|
||||
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
|
||||
type DocumentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
@@ -36,10 +43,11 @@ type DocumentResponse struct {
|
||||
Vendor string `json:"vendor"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
ModelNumber string `json:"model_number"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Images []DocumentImageResponse `json:"images"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Note: Pagination removed - list endpoints now return arrays directly
|
||||
@@ -81,6 +89,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
||||
ModelNumber: d.ModelNumber,
|
||||
TaskID: d.TaskID,
|
||||
IsActive: d.IsActive,
|
||||
Images: make([]DocumentImageResponse, 0),
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
@@ -89,6 +98,15 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,24 @@ type TaskUserResponse struct {
|
||||
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
|
||||
type TaskCompletionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||
Rating *int `json:"rating"`
|
||||
Images []TaskCompletionImageResponse `json:"images"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TaskResponse represents a task in the API response
|
||||
@@ -198,12 +206,21 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
|
||||
CompletedAt: c.CompletedAt,
|
||||
Notes: c.Notes,
|
||||
ActualCost: c.ActualCost,
|
||||
PhotoURL: c.PhotoURL,
|
||||
Rating: c.Rating,
|
||||
Images: make([]TaskCompletionImageResponse, 0),
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
if c.CompletedBy.ID != 0 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) {
|
||||
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/
|
||||
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
|
||||
benefits, err := h.subscriptionService.GetFeatureBenefits()
|
||||
|
||||
@@ -419,7 +419,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.PhotoURL = result.URL
|
||||
req.ImageURLs = append(req.ImageURLs, result.URL)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -37,12 +37,20 @@ type Document struct {
|
||||
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
|
||||
|
||||
// Warranty-specific fields
|
||||
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"`
|
||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
||||
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
||||
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
|
||||
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
|
||||
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"`
|
||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
||||
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
||||
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_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)
|
||||
TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
|
||||
@@ -50,6 +58,9 @@ type Document struct {
|
||||
|
||||
// State
|
||||
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
|
||||
@@ -73,3 +84,16 @@ func (d *Document) IsWarrantyExpired() bool {
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -143,7 +143,10 @@ type TaskCompletion struct {
|
||||
CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"`
|
||||
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||
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
|
||||
@@ -151,6 +154,19 @@ func (TaskCompletion) TableName() string {
|
||||
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
|
||||
type KanbanColumn struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -23,6 +23,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
|
||||
var document models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Task").
|
||||
Preload("Images").
|
||||
Where("id = ? AND is_active = ?", id, true).
|
||||
First(&document).Error
|
||||
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) {
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Images").
|
||||
Where("residence_id = ? AND is_active = ?", residenceID, true).
|
||||
Order("created_at DESC").
|
||||
Find(&documents).Error
|
||||
@@ -46,6 +48,7 @@ func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document,
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Residence").
|
||||
Preload("Images").
|
||||
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
|
||||
Order("created_at DESC").
|
||||
Find(&documents).Error
|
||||
@@ -57,6 +60,7 @@ func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Docum
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Residence").
|
||||
Preload("Images").
|
||||
Where("residence_id IN ? AND is_active = ? AND document_type = ?",
|
||||
residenceIDs, true, models.DocumentTypeWarranty).
|
||||
Order("expiry_date ASC NULLS LAST").
|
||||
@@ -72,6 +76,7 @@ func (r *DocumentRepository) FindExpiringWarranties(residenceIDs []uint, days in
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Residence").
|
||||
Preload("Images").
|
||||
Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?",
|
||||
residenceIDs, true, models.DocumentTypeWarranty, now, threshold).
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -435,6 +435,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
|
||||
var completion models.TaskCompletion
|
||||
err := r.db.Preload("Task").
|
||||
Preload("CompletedBy").
|
||||
Preload("Images").
|
||||
First(&completion, id).Error
|
||||
if err != nil {
|
||||
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) {
|
||||
var completions []models.TaskCompletion
|
||||
err := r.db.Preload("CompletedBy").
|
||||
Preload("Images").
|
||||
Where("task_id = ?", taskID).
|
||||
Order("completed_at DESC").
|
||||
Find(&completions).Error
|
||||
@@ -457,6 +459,7 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
||||
var completions []models.TaskCompletion
|
||||
err := r.db.Preload("Task").
|
||||
Preload("CompletedBy").
|
||||
Preload("Images").
|
||||
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
||||
Where("task_task.residence_id IN ?", residenceIDs).
|
||||
Order("completed_at DESC").
|
||||
@@ -466,9 +469,21 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
||||
|
||||
// DeleteCompletion deletes a task completion
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
type TaskStatistics struct {
|
||||
TotalTasks int
|
||||
|
||||
@@ -325,6 +325,7 @@ func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers
|
||||
{
|
||||
subscription.GET("/", subscriptionHandler.GetSubscription)
|
||||
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
|
||||
subscription.GET("/upgrade-triggers/", subscriptionHandler.GetAllUpgradeTriggers)
|
||||
subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger)
|
||||
subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits)
|
||||
subscription.GET("/promotions/", subscriptionHandler.GetPromotions)
|
||||
|
||||
@@ -142,6 +142,20 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
|
||||
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
|
||||
document, err = s.documentRepo.FindByID(document.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,26 +68,52 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current usage if limitations are enabled
|
||||
var usage *UsageResponse
|
||||
if settings.EnableLimitations {
|
||||
usage, err = s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limitsMap := make(map[string]*TierLimitsClientResponse)
|
||||
for _, l := range allLimits {
|
||||
limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l)
|
||||
}
|
||||
|
||||
return &SubscriptionStatusResponse{
|
||||
Subscription: NewSubscriptionResponse(sub),
|
||||
Limits: NewTierLimitsResponse(limits),
|
||||
Usage: usage,
|
||||
// Ensure both free and pro exist with defaults if missing
|
||||
if _, ok := limitsMap["free"]; !ok {
|
||||
defaults := models.GetDefaultFreeLimits()
|
||||
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,
|
||||
}, 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
|
||||
@@ -121,10 +147,10 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
|
||||
}
|
||||
|
||||
return &UsageResponse{
|
||||
Properties: propertiesCount,
|
||||
Tasks: tasksCount,
|
||||
Contractors: contractorsCount,
|
||||
Documents: documentsCount,
|
||||
PropertiesCount: propertiesCount,
|
||||
TasksCount: tasksCount,
|
||||
ContractorsCount: contractorsCount,
|
||||
DocumentsCount: documentsCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -162,19 +188,19 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
||||
|
||||
switch limitType {
|
||||
case "properties":
|
||||
if limits.PropertiesLimit != nil && usage.Properties >= int64(*limits.PropertiesLimit) {
|
||||
if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
|
||||
return ErrPropertiesLimitExceeded
|
||||
}
|
||||
case "tasks":
|
||||
if limits.TasksLimit != nil && usage.Tasks >= int64(*limits.TasksLimit) {
|
||||
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
|
||||
return ErrTasksLimitExceeded
|
||||
}
|
||||
case "contractors":
|
||||
if limits.ContractorsLimit != nil && usage.Contractors >= int64(*limits.ContractorsLimit) {
|
||||
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
|
||||
return ErrContractorsLimitExceeded
|
||||
}
|
||||
case "documents":
|
||||
if limits.DocumentsLimit != nil && usage.Documents >= int64(*limits.DocumentsLimit) {
|
||||
if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
|
||||
return ErrDocumentsLimitExceeded
|
||||
}
|
||||
}
|
||||
@@ -194,6 +220,21 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
|
||||
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
|
||||
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
||||
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 {
|
||||
Properties int64 `json:"properties"`
|
||||
Tasks int64 `json:"tasks"`
|
||||
Contractors int64 `json:"contractors"`
|
||||
Documents int64 `json:"documents"`
|
||||
PropertiesCount int64 `json:"properties_count"`
|
||||
TasksCount int64 `json:"tasks_count"`
|
||||
ContractorsCount int64 `json:"contractors_count"`
|
||||
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
|
||||
// Fields are flattened to match KMM client expectations
|
||||
type SubscriptionStatusResponse struct {
|
||||
Subscription *SubscriptionResponse `json:"subscription"`
|
||||
Limits *TierLimitsResponse `json:"limits"`
|
||||
Usage *UsageResponse `json:"usage,omitempty"`
|
||||
LimitationsEnabled bool `json:"limitations_enabled"`
|
||||
// Flattened subscription fields (KMM expects these at top level)
|
||||
SubscribedAt *string `json:"subscribed_at"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
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 {
|
||||
TriggerKey string `json:"trigger_key"`
|
||||
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
|
||||
type FeatureBenefitResponse struct {
|
||||
FeatureName string `json:"feature_name"`
|
||||
|
||||
@@ -475,14 +475,27 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
CompletedAt: completedAt,
|
||||
Notes: req.Notes,
|
||||
ActualCost: req.ActualCost,
|
||||
PhotoURL: req.PhotoURL,
|
||||
Rating: req.Rating,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,153 +1,605 @@
|
||||
-- Seed test data for MyCrib
|
||||
-- Run with: ./dev.sh seed-test
|
||||
-- Note: Run ./dev.sh seed first to populate lookup tables
|
||||
-- Run with: POST /api/admin/settings/seed-test-data
|
||||
-- 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
|
||||
INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined)
|
||||
-- =====================================================
|
||||
-- TEST USERS (password is 'password123' for all users)
|
||||
-- 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
|
||||
(1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@example.com', 'Admin', 'User', true, true, true, NOW()),
|
||||
(2, 'john', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john@example.com', 'John', 'Doe', true, false, false, NOW()),
|
||||
(3, 'jane', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane@example.com', 'Jane', 'Smith', true, false, false, NOW()),
|
||||
(4, 'bob', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob@example.com', 'Bob', 'Wilson', true, false, false, 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.doe', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john.doe@example.com', 'John', 'Doe', true, false, false, NOW() - INTERVAL '6 months', NOW() - INTERVAL '2 hours'),
|
||||
(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.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
|
||||
username = EXCLUDED.username,
|
||||
password = EXCLUDED.password,
|
||||
email = EXCLUDED.email,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
is_active = EXCLUDED.is_active;
|
||||
username = EXCLUDED.username, password = EXCLUDED.password, email = EXCLUDED.email,
|
||||
first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, is_active = EXCLUDED.is_active,
|
||||
is_staff = EXCLUDED.is_staff, is_superuser = EXCLUDED.is_superuser;
|
||||
|
||||
-- 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)
|
||||
VALUES
|
||||
(1, NOW(), NOW(), 1, true, '', '', NULL, ''),
|
||||
(2, NOW(), NOW(), 2, true, '', '', NULL, ''),
|
||||
(3, NOW(), NOW(), 3, true, '', '', NULL, ''),
|
||||
(4, NOW(), NOW(), 4, true, '', '', NULL, '')
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
verified = true,
|
||||
updated_at = NOW();
|
||||
(1, NOW(), NOW(), 1, true, 'System administrator', '+1-555-0001', '1985-01-15', 'https://picsum.photos/seed/admin/200'),
|
||||
(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, 'First-time homeowner', '+1-555-0003', '1992-07-08', 'https://picsum.photos/seed/jane/200'),
|
||||
(4, NOW(), NOW(), 4, true, 'Real estate investor', '+1-555-0004', '1975-11-30', 'https://picsum.photos/seed/bob/200'),
|
||||
(5, NOW(), NOW(), 5, true, 'Property manager', '+1-555-0005', '1988-05-17', 'https://picsum.photos/seed/alice/200'),
|
||||
(6, NOW(), NOW(), 6, false, '', '+1-555-0006', NULL, ''),
|
||||
(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
|
||||
(1, NOW(), NOW(), 1, 'pro', NOW(), NOW() + INTERVAL '1 year', true, 'ios'),
|
||||
(2, NOW(), NOW(), 2, 'pro', NOW(), NOW() + INTERVAL '1 year', true, 'android'),
|
||||
(3, NOW(), NOW(), 3, 'free', NULL, NULL, false, NULL),
|
||||
(4, NOW(), NOW(), 4, 'free', NULL, NULL, false, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
tier = EXCLUDED.tier,
|
||||
updated_at = NOW();
|
||||
(1, NOW(), NOW(), 1, 'pro', NOW() - INTERVAL '11 months', NOW() + INTERVAL '1 month', true, 'ios', NULL),
|
||||
(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, NULL),
|
||||
(4, NOW(), NOW(), 4, 'pro', NOW() - INTERVAL '3 months', NOW() + INTERVAL '9 months', true, 'ios', NULL),
|
||||
(5, NOW(), NOW(), 5, 'pro', NOW() - INTERVAL '1 month', NOW() + INTERVAL '11 months', false, 'android', NULL),
|
||||
(6, NOW(), NOW(), 6, 'free', NULL, NULL, false, NULL, NULL),
|
||||
(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
|
||||
(1, NOW(), NOW(), 2, 1, 'Main House', '123 Main Street', 'Springfield', 'IL', '62701', 'USA', true, true),
|
||||
(2, NOW(), NOW(), 2, 7, 'Beach House', '456 Ocean Drive', 'Miami', 'FL', '33139', 'USA', true, false),
|
||||
(3, NOW(), NOW(), 3, 2, 'Downtown Apartment', '789 City Center', 'Los Angeles', 'CA', '90012', 'USA', true, true),
|
||||
(4, NOW(), NOW(), 4, 3, 'Mountain Condo', '321 Peak View', 'Denver', 'CO', '80202', 'USA', true, true)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
street_address = EXCLUDED.street_address,
|
||||
updated_at = NOW();
|
||||
-- John's properties (user 2)
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
|
||||
-- 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)
|
||||
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;
|
||||
|
||||
-- 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
|
||||
(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),
|
||||
(2, NOW(), NOW(), 1, 2, 'Sparky Electric', 'Sparky Electrical Services', '+1-555-1002', 'info@sparky.com', NULL, 'Licensed and insured', false, 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),
|
||||
(4, NOW(), NOW(), 3, 3, 'Handy Andy', NULL, '+1-555-1004', 'andy@handyman.com', NULL, 'General repairs', false, true)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
company = EXCLUDED.company,
|
||||
updated_at = NOW();
|
||||
-- John's contractors (residence 1)
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
|
||||
-- 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)
|
||||
INSERT INTO task_contractor_specialties (contractor_id, contractor_specialty_id)
|
||||
VALUES
|
||||
(1, 1), -- Mike: Plumber
|
||||
(2, 2), -- Sparky: Electrician
|
||||
(3, 3), -- Cool Air: HVAC
|
||||
(4, 4), -- Andy: Handyman
|
||||
(4, 6) -- Andy: Also Painter
|
||||
(1, 1), -- Mike: Plumber
|
||||
(2, 2), -- Sarah: Electrician
|
||||
(3, 3), -- Cool Air: HVAC
|
||||
(4, 4), (4, 6), (4, 8), -- Tom: Handyman, Painter, Carpenter
|
||||
(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;
|
||||
|
||||
-- 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)
|
||||
VALUES
|
||||
-- Residence 1 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),
|
||||
(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),
|
||||
(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),
|
||||
(4, NOW(), NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn maintenance', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '2 days', NULL, NULL, 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 1 (John's Main Home) - 15 tasks =====
|
||||
-- Plumbing tasks
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
|
||||
-- Residence 2 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),
|
||||
(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),
|
||||
-- Electrical tasks
|
||||
(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),
|
||||
(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
|
||||
(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),
|
||||
(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),
|
||||
-- HVAC tasks
|
||||
(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),
|
||||
(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
|
||||
(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)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
-- Outdoor/Landscaping tasks
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
|
||||
-- Test Task Completions
|
||||
INSERT INTO task_taskcompletion (id, created_at, updated_at, task_id, completed_by_id, completed_at, notes, actual_cost)
|
||||
-- Safety tasks
|
||||
(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
|
||||
(1, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days', 4, 3, NOW() - INTERVAL '2 days', 'Lawn looks great!', NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW();
|
||||
-- Completed tasks from above with various ratings and costs
|
||||
(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),
|
||||
(2, NOW() - INTERVAL '60 days', NOW(), 5, 2, NOW() - INTERVAL '60 days', 'All 6 detectors updated with fresh 9V batteries.', 25.00, 4),
|
||||
(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
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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();
|
||||
-- Completion 1 (Drain unclog) - 2 images
|
||||
(1, NOW() - INTERVAL '45 days', NOW(), 1, 'https://picsum.photos/seed/drain1/800/600', 'Before - slow draining sink'),
|
||||
(2, NOW() - INTERVAL '45 days', NOW(), 1, 'https://picsum.photos/seed/drain2/800/600', 'After - drain cleared and flowing'),
|
||||
|
||||
-- Test Notifications
|
||||
INSERT INTO notifications_notification (id, created_at, updated_at, user_id, notification_type, title, body, task_id, sent, read)
|
||||
-- Completion 3 (HVAC filters) - 3 images
|
||||
(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
|
||||
(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),
|
||||
(2, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days', 2, 'task_completed', 'Task Completed', 'Mow lawn has been marked as completed', 4, true, 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)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
body = EXCLUDED.body,
|
||||
updated_at = NOW();
|
||||
-- ===== RESIDENCE 1 - Warranties and documents =====
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
(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)
|
||||
VALUES
|
||||
(1, NOW(), NOW(), 1, 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),
|
||||
(4, NOW(), NOW(), 4, false, false, false, false, false, false)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
task_due_soon = EXCLUDED.task_due_soon,
|
||||
task_overdue = EXCLUDED.task_overdue,
|
||||
updated_at = NOW();
|
||||
(4, NOW(), NOW(), 4, true, true, true, true, true, true),
|
||||
(5, NOW(), NOW(), 5, true, true, true, true, true, true),
|
||||
(6, NOW(), NOW(), 6, false, false, false, false, false, false),
|
||||
(7, NOW(), NOW(), 7, true, false, false, true, true, false),
|
||||
(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('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);
|
||||
@@ -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_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_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_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_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
|
||||
|
||||
Reference in New Issue
Block a user