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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil } from 'lucide-react';
|
import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil, Star } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { completionsApi } from '@/lib/api';
|
import { completionsApi } from '@/lib/api';
|
||||||
@@ -188,6 +188,26 @@ export default function CompletionDetailPage() {
|
|||||||
<span className="text-muted-foreground">Not recorded</span>
|
<span className="text-muted-foreground">Not recorded</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Rating</div>
|
||||||
|
{completion.rating ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
star <= completion.rating!
|
||||||
|
? 'fill-yellow-400 text-yellow-400'
|
||||||
|
: 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="ml-2 text-sm">({completion.rating}/5)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Not rated</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">Record Created</div>
|
<div className="text-sm font-medium text-muted-foreground">Record Created</div>
|
||||||
<div>{new Date(completion.created_at).toLocaleString()}</div>
|
<div>{new Date(completion.created_at).toLocaleString()}</div>
|
||||||
@@ -210,33 +230,40 @@ export default function CompletionDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Photo */}
|
{/* Photos */}
|
||||||
{completion.photo_url && (
|
{completion.images && completion.images.length > 0 && (
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Completion Photo</CardTitle>
|
<CardTitle>Completion Photos ({completion.images.length})</CardTitle>
|
||||||
<CardDescription>Photo evidence of task completion</CardDescription>
|
<CardDescription>Photo evidence of task completion</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="relative max-w-2xl mx-auto">
|
{completion.images.map((image, index) => (
|
||||||
<img
|
<div key={image.id || index} className="space-y-2">
|
||||||
src={completion.photo_url}
|
<div className="relative">
|
||||||
alt="Completion photo"
|
<img
|
||||||
className="rounded-lg border object-contain w-full"
|
src={image.image_url}
|
||||||
/>
|
alt={image.caption || `Completion photo ${index + 1}`}
|
||||||
</div>
|
className="rounded-lg border object-contain w-full"
|
||||||
<div className="flex justify-center">
|
/>
|
||||||
<a
|
</div>
|
||||||
href={completion.photo_url}
|
{image.caption && (
|
||||||
target="_blank"
|
<p className="text-sm text-muted-foreground text-center">{image.caption}</p>
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
<div className="flex justify-center">
|
||||||
>
|
<a
|
||||||
<ExternalLink className="h-4 w-4" />
|
href={image.image_url}
|
||||||
Open full size in new tab
|
target="_blank"
|
||||||
</a>
|
rel="noopener noreferrer"
|
||||||
</div>
|
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Open full size
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -252,34 +252,43 @@ export default function CompletionsPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{completion.photo_url ? (
|
{completion.images && completion.images.length > 0 ? (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<ImageIcon className="h-4 w-4 mr-1" />
|
<ImageIcon className="h-4 w-4 mr-1" />
|
||||||
View
|
{completion.images.length} {completion.images.length === 1 ? 'Photo' : 'Photos'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Completion Photo</DialogTitle>
|
<DialogTitle>Completion Photos ({completion.images.length})</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="relative aspect-video">
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||||
<img
|
{completion.images.map((image, index) => (
|
||||||
src={completion.photo_url}
|
<div key={image.id || index} className="space-y-2">
|
||||||
alt="Completion photo"
|
<div className="relative aspect-video">
|
||||||
className="object-contain w-full h-full rounded-lg"
|
<img
|
||||||
/>
|
src={image.image_url}
|
||||||
|
alt={image.caption || `Completion photo ${index + 1}`}
|
||||||
|
className="object-contain w-full h-full rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{image.caption && (
|
||||||
|
<p className="text-sm text-muted-foreground">{image.caption}</p>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={image.image_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
Open in new tab
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
href={completion.photo_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
Open in new tab
|
|
||||||
</a>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil } from 'lucide-react';
|
import Image from 'next/image';
|
||||||
|
import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil, ImageIcon, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { documentsApi } from '@/lib/api';
|
import { documentsApi } from '@/lib/api';
|
||||||
@@ -16,12 +18,17 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
export function DocumentDetailClient() {
|
export function DocumentDetailClient() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const documentId = Number(params.id);
|
const documentId = Number(params.id);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: document, isLoading, error } = useQuery({
|
const { data: document, isLoading, error } = useQuery({
|
||||||
queryKey: ['document', documentId],
|
queryKey: ['document', documentId],
|
||||||
@@ -181,6 +188,68 @@ export function DocumentDetailClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Document Images */}
|
||||||
|
{document.images && document.images.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
Images ({document.images.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Photos and images attached to this document</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{document.images.map((image) => (
|
||||||
|
<div
|
||||||
|
key={image.id}
|
||||||
|
className="relative aspect-square rounded-lg overflow-hidden border cursor-pointer hover:ring-2 hover:ring-primary transition-all"
|
||||||
|
onClick={() => setSelectedImage(image.image_url)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={image.image_url}
|
||||||
|
alt={image.caption || 'Document image'}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
|
||||||
|
/>
|
||||||
|
{image.caption && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1 truncate">
|
||||||
|
{image.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Modal */}
|
||||||
|
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
|
||||||
|
<DialogContent className="max-w-4xl p-0 overflow-hidden">
|
||||||
|
{selectedImage && (
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 z-10 bg-black/50 hover:bg-black/70 text-white"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Image
|
||||||
|
src={selectedImage}
|
||||||
|
alt="Document image"
|
||||||
|
width={1200}
|
||||||
|
height={800}
|
||||||
|
className="w-full h-auto max-h-[80vh] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Database, TestTube, Shield } from 'lucide-react';
|
import { Database, TestTube } from 'lucide-react';
|
||||||
|
|
||||||
import { settingsApi } from '@/lib/api';
|
import { settingsApi } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -28,24 +26,6 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: settings, isLoading } = useQuery({
|
|
||||||
queryKey: ['settings'],
|
|
||||||
queryFn: settingsApi.get,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: settingsApi.update,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
|
||||||
toast.success('Settings updated');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error('Failed to update settings');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const seedLookupsMutation = useMutation({
|
const seedLookupsMutation = useMutation({
|
||||||
mutationFn: settingsApi.seedLookups,
|
mutationFn: settingsApi.seedLookups,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -66,26 +46,6 @@ export default function SettingsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLimitationsToggle = () => {
|
|
||||||
if (settings) {
|
|
||||||
updateMutation.mutate({
|
|
||||||
enable_limitations: !settings.enable_limitations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-32 bg-gray-200 rounded"></div>
|
|
||||||
<div className="h-32 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -96,35 +56,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* Subscription Limitations */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
Subscription Limitations
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Control whether tier-based limitations are enforced for users
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="enable-limitations">Enable Limitations</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
When enabled, free tier users will have restricted access to features
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="enable-limitations"
|
|
||||||
checked={settings?.enable_limitations ?? false}
|
|
||||||
onCheckedChange={handleLimitationsToggle}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Seed Lookup Data */}
|
{/* Seed Lookup Data */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
Shield,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
@@ -47,6 +50,11 @@ const menuItems = [
|
|||||||
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
|
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const limitationsItems = [
|
||||||
|
{ title: 'Tier Limits', url: '/admin/limitations', icon: Layers },
|
||||||
|
{ title: 'Upgrade Triggers', url: '/admin/limitations/triggers', icon: Sparkles },
|
||||||
|
];
|
||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
||||||
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
||||||
@@ -94,6 +102,27 @@ export function AppSidebar() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Limitations</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{limitationsItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={pathname === item.url || (item.url !== '/admin/limitations' && pathname.startsWith(item.url))}
|
||||||
|
>
|
||||||
|
<a href={item.url}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
|
|||||||
@@ -392,6 +392,13 @@ export const authTokensApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Task Completion Image
|
||||||
|
export interface TaskCompletionImage {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Task Completions Types
|
// Task Completions Types
|
||||||
export interface TaskCompletion {
|
export interface TaskCompletion {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -404,7 +411,8 @@ export interface TaskCompletion {
|
|||||||
completed_at: string;
|
completed_at: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
actual_cost: string | null;
|
actual_cost: string | null;
|
||||||
photo_url: string;
|
rating: number | null;
|
||||||
|
images: TaskCompletionImage[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,4 +672,122 @@ export const settingsApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Limitations types
|
||||||
|
export interface LimitationsSettings {
|
||||||
|
enable_limitations: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TierLimits {
|
||||||
|
id: number;
|
||||||
|
tier: 'free' | 'pro';
|
||||||
|
properties_limit: number | null;
|
||||||
|
tasks_limit: number | null;
|
||||||
|
contractors_limit: number | null;
|
||||||
|
documents_limit: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTierLimitsRequest {
|
||||||
|
properties_limit: number | null;
|
||||||
|
tasks_limit: number | null;
|
||||||
|
contractors_limit: number | null;
|
||||||
|
documents_limit: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeTrigger {
|
||||||
|
id: number;
|
||||||
|
trigger_key: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
promo_html: string;
|
||||||
|
button_text: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerKeyOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUpgradeTriggerRequest {
|
||||||
|
trigger_key: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
promo_html?: string;
|
||||||
|
button_text?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUpgradeTriggerRequest {
|
||||||
|
trigger_key?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
promo_html?: string;
|
||||||
|
button_text?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitations API
|
||||||
|
export const limitationsApi = {
|
||||||
|
// Settings
|
||||||
|
getSettings: async (): Promise<LimitationsSettings> => {
|
||||||
|
const response = await api.get<LimitationsSettings>('/limitations/settings');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings: async (data: { enable_limitations: boolean }): Promise<LimitationsSettings> => {
|
||||||
|
const response = await api.put<LimitationsSettings>('/limitations/settings', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tier Limits
|
||||||
|
listTierLimits: async (): Promise<TierLimits[]> => {
|
||||||
|
const response = await api.get<{ data: TierLimits[]; total: number }>('/limitations/tier-limits');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTierLimits: async (tier: 'free' | 'pro'): Promise<TierLimits> => {
|
||||||
|
const response = await api.get<TierLimits>(`/limitations/tier-limits/${tier}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTierLimits: async (tier: 'free' | 'pro', data: UpdateTierLimitsRequest): Promise<TierLimits> => {
|
||||||
|
const response = await api.put<TierLimits>(`/limitations/tier-limits/${tier}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upgrade Triggers
|
||||||
|
getAvailableTriggerKeys: async (): Promise<TriggerKeyOption[]> => {
|
||||||
|
const response = await api.get<TriggerKeyOption[]>('/limitations/upgrade-triggers/keys');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
listUpgradeTriggers: async (): Promise<UpgradeTrigger[]> => {
|
||||||
|
const response = await api.get<{ data: UpgradeTrigger[]; total: number }>('/limitations/upgrade-triggers');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUpgradeTrigger: async (id: number): Promise<UpgradeTrigger> => {
|
||||||
|
const response = await api.get<UpgradeTrigger>(`/limitations/upgrade-triggers/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createUpgradeTrigger: async (data: CreateUpgradeTriggerRequest): Promise<UpgradeTrigger> => {
|
||||||
|
const response = await api.post<UpgradeTrigger>('/limitations/upgrade-triggers', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUpgradeTrigger: async (id: number, data: UpdateUpgradeTriggerRequest): Promise<UpgradeTrigger> => {
|
||||||
|
const response = await api.put<UpgradeTrigger>(`/limitations/upgrade-triggers/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUpgradeTrigger: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/limitations/upgrade-triggers/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -265,6 +265,12 @@ export interface UpdateContractorRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Document types
|
// Document types
|
||||||
|
export interface DocumentImage {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: number;
|
id: number;
|
||||||
residence_id: number;
|
residence_id: number;
|
||||||
@@ -278,6 +284,7 @@ export interface Document {
|
|||||||
expiry_date?: string;
|
expiry_date?: string;
|
||||||
purchase_date?: string;
|
purchase_date?: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
images: DocumentImage[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +315,7 @@ export interface CreateDocumentRequest {
|
|||||||
serial_number?: string;
|
serial_number?: string;
|
||||||
model_number?: string;
|
model_number?: string;
|
||||||
task_id?: number;
|
task_id?: number;
|
||||||
|
image_urls?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDocumentRequest {
|
export interface UpdateDocumentRequest {
|
||||||
|
|||||||
@@ -152,21 +152,29 @@ type ContractorDetailResponse struct {
|
|||||||
TaskCount int `json:"task_count"`
|
TaskCount int `json:"task_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DocumentImageResponse represents a document image
|
||||||
|
type DocumentImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
// DocumentResponse represents a document in admin responses
|
// DocumentResponse represents a document in admin responses
|
||||||
type DocumentResponse struct {
|
type DocumentResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
ResidenceID uint `json:"residence_id"`
|
ResidenceID uint `json:"residence_id"`
|
||||||
ResidenceName string `json:"residence_name"`
|
ResidenceName string `json:"residence_name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DocumentType string `json:"document_type"`
|
DocumentType string `json:"document_type"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
FileURL string `json:"file_url"`
|
FileURL string `json:"file_url"`
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
ExpiryDate *string `json:"expiry_date,omitempty"`
|
ExpiryDate *string `json:"expiry_date,omitempty"`
|
||||||
PurchaseDate *string `json:"purchase_date,omitempty"`
|
PurchaseDate *string `json:"purchase_date,omitempty"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt string `json:"created_at"`
|
Images []DocumentImageResponse `json:"images"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocumentDetailResponse includes more details for single document view
|
// DocumentDetailResponse includes more details for single document view
|
||||||
|
|||||||
@@ -22,20 +22,28 @@ func NewAdminCompletionHandler(db *gorm.DB) *AdminCompletionHandler {
|
|||||||
return &AdminCompletionHandler{db: db}
|
return &AdminCompletionHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompletionImageResponse represents an image in a completion
|
||||||
|
type CompletionImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
// CompletionResponse represents a task completion in API responses
|
// CompletionResponse represents a task completion in API responses
|
||||||
type CompletionResponse struct {
|
type CompletionResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
TaskID uint `json:"task_id"`
|
TaskID uint `json:"task_id"`
|
||||||
TaskTitle string `json:"task_title"`
|
TaskTitle string `json:"task_title"`
|
||||||
ResidenceID uint `json:"residence_id"`
|
ResidenceID uint `json:"residence_id"`
|
||||||
ResidenceName string `json:"residence_name"`
|
ResidenceName string `json:"residence_name"`
|
||||||
CompletedByID uint `json:"completed_by_id"`
|
CompletedByID uint `json:"completed_by_id"`
|
||||||
CompletedBy string `json:"completed_by"`
|
CompletedBy string `json:"completed_by"`
|
||||||
CompletedAt string `json:"completed_at"`
|
CompletedAt string `json:"completed_at"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
ActualCost *string `json:"actual_cost"`
|
ActualCost *string `json:"actual_cost"`
|
||||||
PhotoURL string `json:"photo_url"`
|
Rating *int `json:"rating"`
|
||||||
CreatedAt string `json:"created_at"`
|
Images []CompletionImageResponse `json:"images"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletionFilters extends PaginationParams with completion-specific filters
|
// CompletionFilters extends PaginationParams with completion-specific filters
|
||||||
@@ -60,7 +68,8 @@ func (h *AdminCompletionHandler) List(c *gin.Context) {
|
|||||||
query := h.db.Model(&models.TaskCompletion{}).
|
query := h.db.Model(&models.TaskCompletion{}).
|
||||||
Preload("Task").
|
Preload("Task").
|
||||||
Preload("Task.Residence").
|
Preload("Task.Residence").
|
||||||
Preload("CompletedBy")
|
Preload("CompletedBy").
|
||||||
|
Preload("Images")
|
||||||
|
|
||||||
// Apply search
|
// Apply search
|
||||||
if filters.Search != "" {
|
if filters.Search != "" {
|
||||||
@@ -125,7 +134,7 @@ func (h *AdminCompletionHandler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var completion models.TaskCompletion
|
var completion models.TaskCompletion
|
||||||
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id).Error; err != nil {
|
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
|
||||||
return
|
return
|
||||||
@@ -229,7 +238,7 @@ func (h *AdminCompletionHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id)
|
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id)
|
||||||
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
|
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +250,8 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
|
|||||||
CompletedByID: completion.CompletedByID,
|
CompletedByID: completion.CompletedByID,
|
||||||
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
|
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
Notes: completion.Notes,
|
Notes: completion.Notes,
|
||||||
PhotoURL: completion.PhotoURL,
|
Rating: completion.Rating,
|
||||||
|
Images: make([]CompletionImageResponse, 0),
|
||||||
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,5 +272,14 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
|
|||||||
response.ActualCost = &cost
|
response.ActualCost = &cost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert images
|
||||||
|
for _, img := range completion.Images {
|
||||||
|
response.Images = append(response.Images, CompletionImageResponse{
|
||||||
|
ID: img.ID,
|
||||||
|
ImageURL: img.ImageURL,
|
||||||
|
Caption: img.Caption,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ func (h *AdminDocumentHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
query := h.db.Model(&models.Document{}).
|
query := h.db.Model(&models.Document{}).
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
Preload("CreatedBy")
|
Preload("CreatedBy").
|
||||||
|
Preload("Images")
|
||||||
|
|
||||||
// Apply search
|
// Apply search
|
||||||
if filters.Search != "" {
|
if filters.Search != "" {
|
||||||
@@ -98,6 +99,7 @@ func (h *AdminDocumentHandler) Get(c *gin.Context) {
|
|||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
Preload("CreatedBy").
|
Preload("CreatedBy").
|
||||||
Preload("Task").
|
Preload("Task").
|
||||||
|
Preload("Images").
|
||||||
First(&document, id).Error; err != nil {
|
First(&document, id).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||||
@@ -166,7 +168,7 @@ func (h *AdminDocumentHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.db.Preload("Residence").Preload("CreatedBy").First(&document, id)
|
h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, id)
|
||||||
c.JSON(http.StatusOK, h.toDocumentResponse(&document))
|
c.JSON(http.StatusOK, h.toDocumentResponse(&document))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +238,7 @@ func (h *AdminDocumentHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.db.Preload("Residence").Preload("CreatedBy").First(&document, document.ID)
|
h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, document.ID)
|
||||||
c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
|
c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +298,7 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
|
|||||||
FileURL: doc.FileURL,
|
FileURL: doc.FileURL,
|
||||||
Vendor: doc.Vendor,
|
Vendor: doc.Vendor,
|
||||||
IsActive: doc.IsActive,
|
IsActive: doc.IsActive,
|
||||||
|
Images: make([]dto.DocumentImageResponse, 0),
|
||||||
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,5 +314,14 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
|
|||||||
response.PurchaseDate = &purchaseDate
|
response.PurchaseDate = &purchaseDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert images
|
||||||
|
for _, img := range doc.Images {
|
||||||
|
response.Images = append(response.Images, dto.DocumentImageResponse{
|
||||||
|
ID: img.ID,
|
||||||
|
ImageURL: img.ImageURL,
|
||||||
|
Caption: img.Caption,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -125,17 +126,29 @@ func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the underlying *sql.DB to execute raw SQL without prepared statements
|
||||||
|
sqlDB, err := h.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Split SQL into individual statements and execute each one
|
// Split SQL into individual statements and execute each one
|
||||||
// This is needed because GORM/PostgreSQL prepared statements don't support multiple commands
|
// This is needed because GORM/PostgreSQL prepared statements don't support multiple commands
|
||||||
statements := splitSQLStatements(string(sqlContent))
|
statements := splitSQLStatements(string(sqlContent))
|
||||||
|
|
||||||
for _, stmt := range statements {
|
for i, stmt := range statements {
|
||||||
stmt = strings.TrimSpace(stmt)
|
stmt = strings.TrimSpace(stmt)
|
||||||
if stmt == "" {
|
if stmt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.db.Exec(stmt).Error; err != nil {
|
// Use the raw sql.DB to avoid GORM's prepared statement handling
|
||||||
return err
|
if _, err := sqlDB.Exec(stmt); err != nil {
|
||||||
|
// Include statement number and first 100 chars for debugging
|
||||||
|
preview := stmt
|
||||||
|
if len(preview) > 100 {
|
||||||
|
preview = preview[:100] + "..."
|
||||||
|
}
|
||||||
|
return fmt.Errorf("statement %d failed: %v\nStatement: %s", i+1, err, preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,28 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
|||||||
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
||||||
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limitations management (tier limits, upgrade triggers)
|
||||||
|
limitationsHandler := handlers.NewAdminLimitationsHandler(db)
|
||||||
|
limitations := protected.Group("/limitations")
|
||||||
|
{
|
||||||
|
// Settings (enable_limitations toggle)
|
||||||
|
limitations.GET("/settings", limitationsHandler.GetSettings)
|
||||||
|
limitations.PUT("/settings", limitationsHandler.UpdateSettings)
|
||||||
|
|
||||||
|
// Tier Limits
|
||||||
|
limitations.GET("/tier-limits", limitationsHandler.ListTierLimits)
|
||||||
|
limitations.GET("/tier-limits/:tier", limitationsHandler.GetTierLimits)
|
||||||
|
limitations.PUT("/tier-limits/:tier", limitationsHandler.UpdateTierLimits)
|
||||||
|
|
||||||
|
// Upgrade Triggers
|
||||||
|
limitations.GET("/upgrade-triggers/keys", limitationsHandler.GetAvailableTriggerKeys)
|
||||||
|
limitations.GET("/upgrade-triggers", limitationsHandler.ListUpgradeTriggers)
|
||||||
|
limitations.POST("/upgrade-triggers", limitationsHandler.CreateUpgradeTrigger)
|
||||||
|
limitations.GET("/upgrade-triggers/:id", limitationsHandler.GetUpgradeTrigger)
|
||||||
|
limitations.PUT("/upgrade-triggers/:id", limitationsHandler.UpdateUpgradeTrigger)
|
||||||
|
limitations.DELETE("/upgrade-triggers/:id", limitationsHandler.DeleteUpgradeTrigger)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ func Migrate() error {
|
|||||||
&models.Contractor{}, // Contractor before Task (Task references Contractor)
|
&models.Contractor{}, // Contractor before Task (Task references Contractor)
|
||||||
&models.Task{},
|
&models.Task{},
|
||||||
&models.TaskCompletion{},
|
&models.TaskCompletion{},
|
||||||
|
&models.TaskCompletionImage{}, // Multiple images per completion
|
||||||
&models.Document{},
|
&models.Document{},
|
||||||
|
&models.DocumentImage{}, // Multiple images per document
|
||||||
|
|
||||||
// Notification tables
|
// Notification tables
|
||||||
&models.Notification{},
|
&models.Notification{},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type CreateDocumentRequest struct {
|
|||||||
SerialNumber string `json:"serial_number" binding:"max=100"`
|
SerialNumber string `json:"serial_number" binding:"max=100"`
|
||||||
ModelNumber string `json:"model_number" binding:"max=100"`
|
ModelNumber string `json:"model_number" binding:"max=100"`
|
||||||
TaskID *uint `json:"task_id"`
|
TaskID *uint `json:"task_id"`
|
||||||
|
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDocumentRequest represents the request to update a document
|
// UpdateDocumentRequest represents the request to update a document
|
||||||
|
|||||||
@@ -88,5 +88,12 @@ type CreateTaskCompletionRequest struct {
|
|||||||
CompletedAt *time.Time `json:"completed_at"` // Defaults to now
|
CompletedAt *time.Time `json:"completed_at"` // Defaults to now
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||||
PhotoURL string `json:"photo_url"`
|
Rating *int `json:"rating"` // 1-5 star rating
|
||||||
|
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompletionImageInput represents an image to add to a completion
|
||||||
|
type CompletionImageInput struct {
|
||||||
|
ImageURL string `json:"image_url" binding:"required"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ type DocumentUserResponse struct {
|
|||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DocumentImageResponse represents an image in a document
|
||||||
|
type DocumentImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
// DocumentResponse represents a document in the API response
|
// DocumentResponse represents a document in the API response
|
||||||
type DocumentResponse struct {
|
type DocumentResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
@@ -36,10 +43,11 @@ type DocumentResponse struct {
|
|||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
SerialNumber string `json:"serial_number"`
|
SerialNumber string `json:"serial_number"`
|
||||||
ModelNumber string `json:"model_number"`
|
ModelNumber string `json:"model_number"`
|
||||||
TaskID *uint `json:"task_id"`
|
TaskID *uint `json:"task_id"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Images []DocumentImageResponse `json:"images"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Pagination removed - list endpoints now return arrays directly
|
// Note: Pagination removed - list endpoints now return arrays directly
|
||||||
@@ -81,6 +89,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
|||||||
ModelNumber: d.ModelNumber,
|
ModelNumber: d.ModelNumber,
|
||||||
TaskID: d.TaskID,
|
TaskID: d.TaskID,
|
||||||
IsActive: d.IsActive,
|
IsActive: d.IsActive,
|
||||||
|
Images: make([]DocumentImageResponse, 0),
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -89,6 +98,15 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
|||||||
resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy)
|
resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert images
|
||||||
|
for _, img := range d.Images {
|
||||||
|
resp.Images = append(resp.Images, DocumentImageResponse{
|
||||||
|
ID: img.ID,
|
||||||
|
ImageURL: img.ImageURL,
|
||||||
|
Caption: img.Caption,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,16 +54,24 @@ type TaskUserResponse struct {
|
|||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskCompletionImageResponse represents a completion image
|
||||||
|
type TaskCompletionImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
// TaskCompletionResponse represents a task completion
|
// TaskCompletionResponse represents a task completion
|
||||||
type TaskCompletionResponse struct {
|
type TaskCompletionResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
TaskID uint `json:"task_id"`
|
TaskID uint `json:"task_id"`
|
||||||
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
|
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
|
||||||
CompletedAt time.Time `json:"completed_at"`
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||||
PhotoURL string `json:"photo_url"`
|
Rating *int `json:"rating"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Images []TaskCompletionImageResponse `json:"images"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskResponse represents a task in the API response
|
// TaskResponse represents a task in the API response
|
||||||
@@ -198,12 +206,21 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
|
|||||||
CompletedAt: c.CompletedAt,
|
CompletedAt: c.CompletedAt,
|
||||||
Notes: c.Notes,
|
Notes: c.Notes,
|
||||||
ActualCost: c.ActualCost,
|
ActualCost: c.ActualCost,
|
||||||
PhotoURL: c.PhotoURL,
|
Rating: c.Rating,
|
||||||
|
Images: make([]TaskCompletionImageResponse, 0),
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
if c.CompletedBy.ID != 0 {
|
if c.CompletedBy.ID != 0 {
|
||||||
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
|
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
|
||||||
}
|
}
|
||||||
|
// Convert images
|
||||||
|
for _, img := range c.Images {
|
||||||
|
resp.Images = append(resp.Images, TaskCompletionImageResponse{
|
||||||
|
ID: img.ID,
|
||||||
|
ImageURL: img.ImageURL,
|
||||||
|
Caption: img.Caption,
|
||||||
|
})
|
||||||
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, trigger)
|
c.JSON(http.StatusOK, trigger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllUpgradeTriggers handles GET /api/subscription/upgrade-triggers/
|
||||||
|
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c *gin.Context) {
|
||||||
|
triggers, err := h.subscriptionService.GetAllUpgradeTriggers()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, triggers)
|
||||||
|
}
|
||||||
|
|
||||||
// GetFeatureBenefits handles GET /api/subscription/features/
|
// GetFeatureBenefits handles GET /api/subscription/features/
|
||||||
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
|
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
|
||||||
benefits, err := h.subscriptionService.GetFeatureBenefits()
|
benefits, err := h.subscriptionService.GetFeatureBenefits()
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.PhotoURL = result.URL
|
req.ImageURLs = append(req.ImageURLs, result.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -37,12 +37,20 @@ type Document struct {
|
|||||||
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
|
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
|
||||||
|
|
||||||
// Warranty-specific fields
|
// Warranty-specific fields
|
||||||
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
|
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
|
||||||
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
|
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
|
||||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
||||||
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
||||||
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
|
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
|
||||||
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
|
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
|
||||||
|
|
||||||
|
// Warranty provider contact fields
|
||||||
|
Provider string `gorm:"column:provider;size:200" json:"provider"`
|
||||||
|
ProviderContact string `gorm:"column:provider_contact;size:200" json:"provider_contact"`
|
||||||
|
ClaimPhone string `gorm:"column:claim_phone;size:50" json:"claim_phone"`
|
||||||
|
ClaimEmail string `gorm:"column:claim_email;size:200" json:"claim_email"`
|
||||||
|
ClaimWebsite string `gorm:"column:claim_website;size:500" json:"claim_website"`
|
||||||
|
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||||
|
|
||||||
// Associated task (optional)
|
// Associated task (optional)
|
||||||
TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
|
TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
|
||||||
@@ -50,6 +58,9 @@ type Document struct {
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||||
|
|
||||||
|
// Multiple images support
|
||||||
|
Images []DocumentImage `gorm:"foreignKey:DocumentID" json:"images,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for GORM
|
// TableName returns the table name for GORM
|
||||||
@@ -73,3 +84,16 @@ func (d *Document) IsWarrantyExpired() bool {
|
|||||||
}
|
}
|
||||||
return time.Now().UTC().After(*d.ExpiryDate)
|
return time.Now().UTC().After(*d.ExpiryDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DocumentImage represents the task_documentimage table
|
||||||
|
type DocumentImage struct {
|
||||||
|
BaseModel
|
||||||
|
DocumentID uint `gorm:"column:document_id;index;not null" json:"document_id"`
|
||||||
|
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
|
||||||
|
Caption string `gorm:"column:caption;size:255" json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for GORM
|
||||||
|
func (DocumentImage) TableName() string {
|
||||||
|
return "task_documentimage"
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ type TaskCompletion struct {
|
|||||||
CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"`
|
CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"`
|
||||||
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||||
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
|
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
|
||||||
PhotoURL string `gorm:"column:photo_url;size:500" json:"photo_url"`
|
Rating *int `gorm:"column:rating" json:"rating"` // 1-5 star rating
|
||||||
|
|
||||||
|
// Multiple images support
|
||||||
|
Images []TaskCompletionImage `gorm:"foreignKey:CompletionID" json:"images,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for GORM
|
// TableName returns the table name for GORM
|
||||||
@@ -151,6 +154,19 @@ func (TaskCompletion) TableName() string {
|
|||||||
return "task_taskcompletion"
|
return "task_taskcompletion"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskCompletionImage represents the task_taskcompletionimage table
|
||||||
|
type TaskCompletionImage struct {
|
||||||
|
BaseModel
|
||||||
|
CompletionID uint `gorm:"column:completion_id;index;not null" json:"completion_id"`
|
||||||
|
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
|
||||||
|
Caption string `gorm:"column:caption;size:255" json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for GORM
|
||||||
|
func (TaskCompletionImage) TableName() string {
|
||||||
|
return "task_taskcompletionimage"
|
||||||
|
}
|
||||||
|
|
||||||
// KanbanColumn represents a column in the kanban board
|
// KanbanColumn represents a column in the kanban board
|
||||||
type KanbanColumn struct {
|
type KanbanColumn struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
|
|||||||
var document models.Document
|
var document models.Document
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("Task").
|
Preload("Task").
|
||||||
|
Preload("Images").
|
||||||
Where("id = ? AND is_active = ?", id, true).
|
Where("id = ? AND is_active = ?", id, true).
|
||||||
First(&document).Error
|
First(&document).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,6 +36,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
|
|||||||
func (r *DocumentRepository) FindByResidence(residenceID uint) ([]models.Document, error) {
|
func (r *DocumentRepository) FindByResidence(residenceID uint) ([]models.Document, error) {
|
||||||
var documents []models.Document
|
var documents []models.Document
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
|
Preload("Images").
|
||||||
Where("residence_id = ? AND is_active = ?", residenceID, true).
|
Where("residence_id = ? AND is_active = ?", residenceID, true).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Find(&documents).Error
|
Find(&documents).Error
|
||||||
@@ -46,6 +48,7 @@ func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document,
|
|||||||
var documents []models.Document
|
var documents []models.Document
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
|
Preload("Images").
|
||||||
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
|
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Find(&documents).Error
|
Find(&documents).Error
|
||||||
@@ -57,6 +60,7 @@ func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Docum
|
|||||||
var documents []models.Document
|
var documents []models.Document
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
|
Preload("Images").
|
||||||
Where("residence_id IN ? AND is_active = ? AND document_type = ?",
|
Where("residence_id IN ? AND is_active = ? AND document_type = ?",
|
||||||
residenceIDs, true, models.DocumentTypeWarranty).
|
residenceIDs, true, models.DocumentTypeWarranty).
|
||||||
Order("expiry_date ASC NULLS LAST").
|
Order("expiry_date ASC NULLS LAST").
|
||||||
@@ -72,6 +76,7 @@ func (r *DocumentRepository) FindExpiringWarranties(residenceIDs []uint, days in
|
|||||||
var documents []models.Document
|
var documents []models.Document
|
||||||
err := r.db.Preload("CreatedBy").
|
err := r.db.Preload("CreatedBy").
|
||||||
Preload("Residence").
|
Preload("Residence").
|
||||||
|
Preload("Images").
|
||||||
Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?",
|
Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?",
|
||||||
residenceIDs, true, models.DocumentTypeWarranty, now, threshold).
|
residenceIDs, true, models.DocumentTypeWarranty, now, threshold).
|
||||||
Order("expiry_date ASC").
|
Order("expiry_date ASC").
|
||||||
@@ -121,5 +126,20 @@ func (r *DocumentRepository) CountByResidence(residenceID uint) (int64, error) {
|
|||||||
|
|
||||||
// FindByIDIncludingInactive finds a document by ID including inactive ones
|
// FindByIDIncludingInactive finds a document by ID including inactive ones
|
||||||
func (r *DocumentRepository) FindByIDIncludingInactive(id uint, document *models.Document) error {
|
func (r *DocumentRepository) FindByIDIncludingInactive(id uint, document *models.Document) error {
|
||||||
return r.db.Preload("CreatedBy").First(document, id).Error
|
return r.db.Preload("CreatedBy").Preload("Images").First(document, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDocumentImage creates a new document image
|
||||||
|
func (r *DocumentRepository) CreateDocumentImage(image *models.DocumentImage) error {
|
||||||
|
return r.db.Create(image).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDocumentImage deletes a document image
|
||||||
|
func (r *DocumentRepository) DeleteDocumentImage(id uint) error {
|
||||||
|
return r.db.Delete(&models.DocumentImage{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDocumentImages deletes all images for a document
|
||||||
|
func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error {
|
||||||
|
return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentImage{}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
|
|||||||
var completion models.TaskCompletion
|
var completion models.TaskCompletion
|
||||||
err := r.db.Preload("Task").
|
err := r.db.Preload("Task").
|
||||||
Preload("CompletedBy").
|
Preload("CompletedBy").
|
||||||
|
Preload("Images").
|
||||||
First(&completion, id).Error
|
First(&completion, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -446,6 +447,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
|
|||||||
func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) {
|
func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) {
|
||||||
var completions []models.TaskCompletion
|
var completions []models.TaskCompletion
|
||||||
err := r.db.Preload("CompletedBy").
|
err := r.db.Preload("CompletedBy").
|
||||||
|
Preload("Images").
|
||||||
Where("task_id = ?", taskID).
|
Where("task_id = ?", taskID).
|
||||||
Order("completed_at DESC").
|
Order("completed_at DESC").
|
||||||
Find(&completions).Error
|
Find(&completions).Error
|
||||||
@@ -457,6 +459,7 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
|||||||
var completions []models.TaskCompletion
|
var completions []models.TaskCompletion
|
||||||
err := r.db.Preload("Task").
|
err := r.db.Preload("Task").
|
||||||
Preload("CompletedBy").
|
Preload("CompletedBy").
|
||||||
|
Preload("Images").
|
||||||
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
||||||
Where("task_task.residence_id IN ?", residenceIDs).
|
Where("task_task.residence_id IN ?", residenceIDs).
|
||||||
Order("completed_at DESC").
|
Order("completed_at DESC").
|
||||||
@@ -466,9 +469,21 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
|||||||
|
|
||||||
// DeleteCompletion deletes a task completion
|
// DeleteCompletion deletes a task completion
|
||||||
func (r *TaskRepository) DeleteCompletion(id uint) error {
|
func (r *TaskRepository) DeleteCompletion(id uint) error {
|
||||||
|
// Delete images first
|
||||||
|
r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{})
|
||||||
return r.db.Delete(&models.TaskCompletion{}, id).Error
|
return r.db.Delete(&models.TaskCompletion{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateCompletionImage creates a new completion image
|
||||||
|
func (r *TaskRepository) CreateCompletionImage(image *models.TaskCompletionImage) error {
|
||||||
|
return r.db.Create(image).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCompletionImage deletes a completion image
|
||||||
|
func (r *TaskRepository) DeleteCompletionImage(id uint) error {
|
||||||
|
return r.db.Delete(&models.TaskCompletionImage{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
// TaskStatistics represents aggregated task statistics
|
// TaskStatistics represents aggregated task statistics
|
||||||
type TaskStatistics struct {
|
type TaskStatistics struct {
|
||||||
TotalTasks int
|
TotalTasks int
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers
|
|||||||
{
|
{
|
||||||
subscription.GET("/", subscriptionHandler.GetSubscription)
|
subscription.GET("/", subscriptionHandler.GetSubscription)
|
||||||
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
|
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
|
||||||
|
subscription.GET("/upgrade-triggers/", subscriptionHandler.GetAllUpgradeTriggers)
|
||||||
subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger)
|
subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger)
|
||||||
subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits)
|
subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits)
|
||||||
subscription.GET("/promotions/", subscriptionHandler.GetPromotions)
|
subscription.GET("/promotions/", subscriptionHandler.GetPromotions)
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create images if provided
|
||||||
|
for _, imageURL := range req.ImageURLs {
|
||||||
|
if imageURL != "" {
|
||||||
|
img := &models.DocumentImage{
|
||||||
|
DocumentID: document.ID,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
}
|
||||||
|
if err := s.documentRepo.CreateDocumentImage(img); err != nil {
|
||||||
|
// Log but don't fail the whole operation
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reload with relations
|
// Reload with relations
|
||||||
document, err = s.documentRepo.FindByID(document.ID)
|
document, err = s.documentRepo.FindByID(document.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -68,26 +68,52 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
// Get all tier limits and build a map
|
||||||
|
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current usage if limitations are enabled
|
limitsMap := make(map[string]*TierLimitsClientResponse)
|
||||||
var usage *UsageResponse
|
for _, l := range allLimits {
|
||||||
if settings.EnableLimitations {
|
limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l)
|
||||||
usage, err = s.getUserUsage(userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SubscriptionStatusResponse{
|
// Ensure both free and pro exist with defaults if missing
|
||||||
Subscription: NewSubscriptionResponse(sub),
|
if _, ok := limitsMap["free"]; !ok {
|
||||||
Limits: NewTierLimitsResponse(limits),
|
defaults := models.GetDefaultFreeLimits()
|
||||||
Usage: usage,
|
limitsMap["free"] = NewTierLimitsClientResponse(&defaults)
|
||||||
|
}
|
||||||
|
if _, ok := limitsMap["pro"]; !ok {
|
||||||
|
defaults := models.GetDefaultProLimits()
|
||||||
|
limitsMap["pro"] = NewTierLimitsClientResponse(&defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current usage
|
||||||
|
usage, err := s.getUserUsage(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build flattened response (KMM expects subscription fields at top level)
|
||||||
|
resp := &SubscriptionStatusResponse{
|
||||||
|
AutoRenew: sub.AutoRenew,
|
||||||
|
Limits: limitsMap,
|
||||||
|
Usage: usage,
|
||||||
LimitationsEnabled: settings.EnableLimitations,
|
LimitationsEnabled: settings.EnableLimitations,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Format dates if present
|
||||||
|
if sub.SubscribedAt != nil {
|
||||||
|
t := sub.SubscribedAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
resp.SubscribedAt = &t
|
||||||
|
}
|
||||||
|
if sub.ExpiresAt != nil {
|
||||||
|
t := sub.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
resp.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserUsage calculates current usage for a user
|
// getUserUsage calculates current usage for a user
|
||||||
@@ -121,10 +147,10 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &UsageResponse{
|
return &UsageResponse{
|
||||||
Properties: propertiesCount,
|
PropertiesCount: propertiesCount,
|
||||||
Tasks: tasksCount,
|
TasksCount: tasksCount,
|
||||||
Contractors: contractorsCount,
|
ContractorsCount: contractorsCount,
|
||||||
Documents: documentsCount,
|
DocumentsCount: documentsCount,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,19 +188,19 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
|||||||
|
|
||||||
switch limitType {
|
switch limitType {
|
||||||
case "properties":
|
case "properties":
|
||||||
if limits.PropertiesLimit != nil && usage.Properties >= int64(*limits.PropertiesLimit) {
|
if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
|
||||||
return ErrPropertiesLimitExceeded
|
return ErrPropertiesLimitExceeded
|
||||||
}
|
}
|
||||||
case "tasks":
|
case "tasks":
|
||||||
if limits.TasksLimit != nil && usage.Tasks >= int64(*limits.TasksLimit) {
|
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
|
||||||
return ErrTasksLimitExceeded
|
return ErrTasksLimitExceeded
|
||||||
}
|
}
|
||||||
case "contractors":
|
case "contractors":
|
||||||
if limits.ContractorsLimit != nil && usage.Contractors >= int64(*limits.ContractorsLimit) {
|
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
|
||||||
return ErrContractorsLimitExceeded
|
return ErrContractorsLimitExceeded
|
||||||
}
|
}
|
||||||
case "documents":
|
case "documents":
|
||||||
if limits.DocumentsLimit != nil && usage.Documents >= int64(*limits.DocumentsLimit) {
|
if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
|
||||||
return ErrDocumentsLimitExceeded
|
return ErrDocumentsLimitExceeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,6 +220,21 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
|
|||||||
return NewUpgradeTriggerResponse(trigger), nil
|
return NewUpgradeTriggerResponse(trigger), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key
|
||||||
|
// KMM client expects Map<String, UpgradeTriggerData>
|
||||||
|
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
|
||||||
|
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]*UpgradeTriggerDataResponse)
|
||||||
|
for _, t := range triggers {
|
||||||
|
result[t.TriggerKey] = NewUpgradeTriggerDataResponse(&t)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetFeatureBenefits gets all feature benefits
|
// GetFeatureBenefits gets all feature benefits
|
||||||
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
||||||
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
|
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
|
||||||
@@ -331,23 +372,47 @@ func NewTierLimitsResponse(l *models.TierLimits) *TierLimitsResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageResponse represents current usage
|
// UsageResponse represents current usage (KMM client expects _count suffix)
|
||||||
type UsageResponse struct {
|
type UsageResponse struct {
|
||||||
Properties int64 `json:"properties"`
|
PropertiesCount int64 `json:"properties_count"`
|
||||||
Tasks int64 `json:"tasks"`
|
TasksCount int64 `json:"tasks_count"`
|
||||||
Contractors int64 `json:"contractors"`
|
ContractorsCount int64 `json:"contractors_count"`
|
||||||
Documents int64 `json:"documents"`
|
DocumentsCount int64 `json:"documents_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierLimitsClientResponse represents tier limits for mobile client (simple field names)
|
||||||
|
type TierLimitsClientResponse struct {
|
||||||
|
Properties *int `json:"properties"`
|
||||||
|
Tasks *int `json:"tasks"`
|
||||||
|
Contractors *int `json:"contractors"`
|
||||||
|
Documents *int `json:"documents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTierLimitsClientResponse creates a TierLimitsClientResponse from a model
|
||||||
|
func NewTierLimitsClientResponse(l *models.TierLimits) *TierLimitsClientResponse {
|
||||||
|
return &TierLimitsClientResponse{
|
||||||
|
Properties: l.PropertiesLimit,
|
||||||
|
Tasks: l.TasksLimit,
|
||||||
|
Contractors: l.ContractorsLimit,
|
||||||
|
Documents: l.DocumentsLimit,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscriptionStatusResponse represents full subscription status
|
// SubscriptionStatusResponse represents full subscription status
|
||||||
|
// Fields are flattened to match KMM client expectations
|
||||||
type SubscriptionStatusResponse struct {
|
type SubscriptionStatusResponse struct {
|
||||||
Subscription *SubscriptionResponse `json:"subscription"`
|
// Flattened subscription fields (KMM expects these at top level)
|
||||||
Limits *TierLimitsResponse `json:"limits"`
|
SubscribedAt *string `json:"subscribed_at"`
|
||||||
Usage *UsageResponse `json:"usage,omitempty"`
|
ExpiresAt *string `json:"expires_at"`
|
||||||
LimitationsEnabled bool `json:"limitations_enabled"`
|
AutoRenew bool `json:"auto_renew"`
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
Usage *UsageResponse `json:"usage"`
|
||||||
|
Limits map[string]*TierLimitsClientResponse `json:"limits"`
|
||||||
|
LimitationsEnabled bool `json:"limitations_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpgradeTriggerResponse represents an upgrade trigger
|
// UpgradeTriggerResponse represents an upgrade trigger (includes trigger_key)
|
||||||
type UpgradeTriggerResponse struct {
|
type UpgradeTriggerResponse struct {
|
||||||
TriggerKey string `json:"trigger_key"`
|
TriggerKey string `json:"trigger_key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -367,6 +432,29 @@ func NewUpgradeTriggerResponse(t *models.UpgradeTrigger) *UpgradeTriggerResponse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpgradeTriggerDataResponse represents trigger data for map values (no trigger_key)
|
||||||
|
// Matches KMM UpgradeTriggerData model
|
||||||
|
type UpgradeTriggerDataResponse struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
PromoHTML *string `json:"promo_html"`
|
||||||
|
ButtonText string `json:"button_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpgradeTriggerDataResponse creates an UpgradeTriggerDataResponse from a model
|
||||||
|
func NewUpgradeTriggerDataResponse(t *models.UpgradeTrigger) *UpgradeTriggerDataResponse {
|
||||||
|
var promoHTML *string
|
||||||
|
if t.PromoHTML != "" {
|
||||||
|
promoHTML = &t.PromoHTML
|
||||||
|
}
|
||||||
|
return &UpgradeTriggerDataResponse{
|
||||||
|
Title: t.Title,
|
||||||
|
Message: t.Message,
|
||||||
|
PromoHTML: promoHTML,
|
||||||
|
ButtonText: t.ButtonText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FeatureBenefitResponse represents a feature benefit
|
// FeatureBenefitResponse represents a feature benefit
|
||||||
type FeatureBenefitResponse struct {
|
type FeatureBenefitResponse struct {
|
||||||
FeatureName string `json:"feature_name"`
|
FeatureName string `json:"feature_name"`
|
||||||
|
|||||||
@@ -475,14 +475,27 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
|||||||
CompletedAt: completedAt,
|
CompletedAt: completedAt,
|
||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
ActualCost: req.ActualCost,
|
ActualCost: req.ActualCost,
|
||||||
PhotoURL: req.PhotoURL,
|
Rating: req.Rating,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload completion with user info
|
// Create images if provided
|
||||||
|
for _, imageURL := range req.ImageURLs {
|
||||||
|
if imageURL != "" {
|
||||||
|
img := &models.TaskCompletionImage{
|
||||||
|
CompletionID: completion.ID,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
}
|
||||||
|
if err := s.taskRepo.CreateCompletionImage(img); err != nil {
|
||||||
|
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload completion with user info and images
|
||||||
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
|
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,153 +1,605 @@
|
|||||||
-- Seed test data for MyCrib
|
-- Seed test data for MyCrib
|
||||||
-- Run with: ./dev.sh seed-test
|
-- Run with: POST /api/admin/settings/seed-test-data
|
||||||
-- Note: Run ./dev.sh seed first to populate lookup tables
|
-- Note: Run seed-lookups first to populate lookup tables
|
||||||
|
|
||||||
-- Test Users (password is 'password123' hashed with bcrypt)
|
-- =====================================================
|
||||||
-- bcrypt hash for 'password123': $2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi
|
-- TEST USERS (password is 'password123' for all users)
|
||||||
INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined)
|
-- bcrypt hash: $2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO auth_user (id, username, password, email, first_name, last_name, is_active, is_staff, is_superuser, date_joined, last_login)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@example.com', 'Admin', 'User', true, true, true, NOW()),
|
(1, 'admin', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'admin@mycrib.com', 'Admin', 'User', true, true, true, NOW() - INTERVAL '1 year', NOW() - INTERVAL '1 hour'),
|
||||||
(2, 'john', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john@example.com', 'John', 'Doe', true, false, false, NOW()),
|
(2, 'john.doe', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'john.doe@example.com', 'John', 'Doe', true, false, false, NOW() - INTERVAL '6 months', NOW() - INTERVAL '2 hours'),
|
||||||
(3, 'jane', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane@example.com', 'Jane', 'Smith', true, false, false, NOW()),
|
(3, 'jane.smith', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'jane.smith@example.com', 'Jane', 'Smith', true, false, false, NOW() - INTERVAL '5 months', NOW() - INTERVAL '1 day'),
|
||||||
(4, 'bob', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob@example.com', 'Bob', 'Wilson', true, false, false, NOW())
|
(4, 'bob.wilson', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'bob.wilson@example.com', 'Bob', 'Wilson', true, false, false, NOW() - INTERVAL '4 months', NOW() - INTERVAL '3 days'),
|
||||||
|
(5, 'alice.johnson', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'alice.johnson@example.com', 'Alice', 'Johnson', true, false, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '1 week'),
|
||||||
|
(6, 'charlie.brown', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'charlie.brown@example.com', 'Charlie', 'Brown', true, false, false, NOW() - INTERVAL '2 months', NOW() - INTERVAL '2 weeks'),
|
||||||
|
(7, 'diana.ross', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'diana.ross@example.com', 'Diana', 'Ross', true, false, false, NOW() - INTERVAL '1 month', NULL),
|
||||||
|
(8, 'edward.norton', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'edward.norton@example.com', 'Edward', 'Norton', true, false, false, NOW() - INTERVAL '2 weeks', NOW() - INTERVAL '5 days'),
|
||||||
|
(9, 'fiona.apple', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'fiona.apple@example.com', 'Fiona', 'Apple', true, false, false, NOW() - INTERVAL '1 week', NOW()),
|
||||||
|
(10, 'inactive.user', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'inactive@example.com', 'Inactive', 'User', false, false, false, NOW() - INTERVAL '1 year', NULL),
|
||||||
|
(11, 'staff.member', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'staff@mycrib.com', 'Staff', 'Member', true, true, false, NOW() - INTERVAL '3 months', NOW() - INTERVAL '6 hours'),
|
||||||
|
(12, 'george.harrison', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'george@example.com', 'George', 'Harrison', true, false, false, NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days')
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
username = EXCLUDED.username,
|
username = EXCLUDED.username, password = EXCLUDED.password, email = EXCLUDED.email,
|
||||||
password = EXCLUDED.password,
|
first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, is_active = EXCLUDED.is_active,
|
||||||
email = EXCLUDED.email,
|
is_staff = EXCLUDED.is_staff, is_superuser = EXCLUDED.is_superuser;
|
||||||
first_name = EXCLUDED.first_name,
|
|
||||||
last_name = EXCLUDED.last_name,
|
|
||||||
is_active = EXCLUDED.is_active;
|
|
||||||
|
|
||||||
-- User Profiles (email verified)
|
-- =====================================================
|
||||||
|
-- USER PROFILES
|
||||||
|
-- =====================================================
|
||||||
INSERT INTO user_userprofile (id, created_at, updated_at, user_id, verified, bio, phone_number, date_of_birth, profile_picture)
|
INSERT INTO user_userprofile (id, created_at, updated_at, user_id, verified, bio, phone_number, date_of_birth, profile_picture)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW(), NOW(), 1, true, '', '', NULL, ''),
|
(1, NOW(), NOW(), 1, true, 'System administrator', '+1-555-0001', '1985-01-15', 'https://picsum.photos/seed/admin/200'),
|
||||||
(2, NOW(), NOW(), 2, true, '', '', NULL, ''),
|
(2, NOW(), NOW(), 2, true, 'Homeowner with multiple properties', '+1-555-0002', '1980-03-22', 'https://picsum.photos/seed/john/200'),
|
||||||
(3, NOW(), NOW(), 3, true, '', '', NULL, ''),
|
(3, NOW(), NOW(), 3, true, 'First-time homeowner', '+1-555-0003', '1992-07-08', 'https://picsum.photos/seed/jane/200'),
|
||||||
(4, NOW(), NOW(), 4, true, '', '', NULL, '')
|
(4, NOW(), NOW(), 4, true, 'Real estate investor', '+1-555-0004', '1975-11-30', 'https://picsum.photos/seed/bob/200'),
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
(5, NOW(), NOW(), 5, true, 'Property manager', '+1-555-0005', '1988-05-17', 'https://picsum.photos/seed/alice/200'),
|
||||||
verified = true,
|
(6, NOW(), NOW(), 6, false, '', '+1-555-0006', NULL, ''),
|
||||||
updated_at = NOW();
|
(7, NOW(), NOW(), 7, false, 'New to the app', '', NULL, ''),
|
||||||
|
(8, NOW(), NOW(), 8, true, 'DIY enthusiast', '+1-555-0008', '1990-09-25', 'https://picsum.photos/seed/edward/200'),
|
||||||
|
(9, NOW(), NOW(), 9, true, 'Interior designer', '+1-555-0009', '1995-02-14', 'https://picsum.photos/seed/fiona/200'),
|
||||||
|
(10, NOW(), NOW(), 10, false, '', '', NULL, ''),
|
||||||
|
(11, NOW(), NOW(), 11, true, 'Customer support', '+1-555-0011', '1987-06-20', 'https://picsum.photos/seed/staff/200'),
|
||||||
|
(12, NOW(), NOW(), 12, true, 'Weekend warrior', '+1-555-0012', '1982-12-05', 'https://picsum.photos/seed/george/200')
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET verified = EXCLUDED.verified, bio = EXCLUDED.bio, updated_at = NOW();
|
||||||
|
|
||||||
-- User Subscriptions
|
-- =====================================================
|
||||||
INSERT INTO subscription_usersubscription (id, created_at, updated_at, user_id, tier, subscribed_at, expires_at, auto_renew, platform)
|
-- USER SUBSCRIPTIONS (mix of free and pro, various platforms)
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO subscription_usersubscription (id, created_at, updated_at, user_id, tier, subscribed_at, expires_at, auto_renew, platform, cancelled_at)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW(), NOW(), 1, 'pro', NOW(), NOW() + INTERVAL '1 year', true, 'ios'),
|
(1, NOW(), NOW(), 1, 'pro', NOW() - INTERVAL '11 months', NOW() + INTERVAL '1 month', true, 'ios', NULL),
|
||||||
(2, NOW(), NOW(), 2, 'pro', NOW(), NOW() + INTERVAL '1 year', true, 'android'),
|
(2, NOW(), NOW(), 2, 'pro', NOW() - INTERVAL '6 months', NOW() + INTERVAL '6 months', true, 'android', NULL),
|
||||||
(3, NOW(), NOW(), 3, 'free', NULL, NULL, false, NULL),
|
(3, NOW(), NOW(), 3, 'free', NULL, NULL, false, NULL, NULL),
|
||||||
(4, NOW(), NOW(), 4, 'free', NULL, NULL, false, NULL)
|
(4, NOW(), NOW(), 4, 'pro', NOW() - INTERVAL '3 months', NOW() + INTERVAL '9 months', true, 'ios', NULL),
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
(5, NOW(), NOW(), 5, 'pro', NOW() - INTERVAL '1 month', NOW() + INTERVAL '11 months', false, 'android', NULL),
|
||||||
tier = EXCLUDED.tier,
|
(6, NOW(), NOW(), 6, 'free', NULL, NULL, false, NULL, NULL),
|
||||||
updated_at = NOW();
|
(7, NOW(), NOW(), 7, 'free', NULL, NULL, false, NULL, NULL),
|
||||||
|
(8, NOW(), NOW(), 8, 'pro', NOW() - INTERVAL '2 months', NOW() - INTERVAL '1 month', false, 'ios', NOW() - INTERVAL '1 month'),
|
||||||
|
(9, NOW(), NOW(), 9, 'pro', NOW() - INTERVAL '1 week', NOW() + INTERVAL '1 year', true, 'android', NULL),
|
||||||
|
(10, NOW(), NOW(), 10, 'free', NULL, NULL, false, NULL, NULL),
|
||||||
|
(11, NOW(), NOW(), 11, 'pro', NOW() - INTERVAL '6 months', NOW() + INTERVAL '6 months', true, 'ios', NULL),
|
||||||
|
(12, NOW(), NOW(), 12, 'free', NULL, NULL, false, NULL, NULL)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET tier = EXCLUDED.tier, updated_at = NOW();
|
||||||
|
|
||||||
-- Test Residences (using Go/GORM schema: street_address, state_province, postal_code)
|
-- =====================================================
|
||||||
INSERT INTO residence_residence (id, created_at, updated_at, owner_id, property_type_id, name, street_address, city, state_province, postal_code, country, is_active, is_primary)
|
-- RESIDENCES (all property types, various locations)
|
||||||
|
-- Property types: 1=House, 2=Apartment, 3=Condo, 4=Townhouse, 5=Mobile Home, 6=Multi-Family, 7=Vacation Home
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO residence_residence (id, created_at, updated_at, owner_id, property_type_id, name, street_address, apartment_unit, city, state_province, postal_code, country, bedrooms, bathrooms, square_footage, year_built, description, is_active, is_primary)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW(), NOW(), 2, 1, 'Main House', '123 Main Street', 'Springfield', 'IL', '62701', 'USA', true, true),
|
-- John's properties (user 2)
|
||||||
(2, NOW(), NOW(), 2, 7, 'Beach House', '456 Ocean Drive', 'Miami', 'FL', '33139', 'USA', true, false),
|
(1, NOW() - INTERVAL '6 months', NOW(), 2, 1, 'Main Family Home', '123 Oak Street', NULL, 'Springfield', 'IL', '62701', 'USA', 4, 3, 2500, 1995, 'Beautiful colonial style home with large backyard', true, true),
|
||||||
(3, NOW(), NOW(), 3, 2, 'Downtown Apartment', '789 City Center', 'Los Angeles', 'CA', '90012', 'USA', true, true),
|
(2, NOW() - INTERVAL '5 months', NOW(), 2, 7, 'Beach Getaway', '456 Ocean Boulevard', NULL, 'Miami Beach', 'FL', '33139', 'USA', 3, 2, 1800, 2010, 'Oceanfront vacation property', true, false),
|
||||||
(4, NOW(), NOW(), 4, 3, 'Mountain Condo', '321 Peak View', 'Denver', 'CO', '80202', 'USA', true, true)
|
(3, NOW() - INTERVAL '4 months', NOW(), 2, 6, 'Investment Duplex', '789 Rental Road', NULL, 'Chicago', 'IL', '60601', 'USA', 4, 2, 2200, 1985, 'Duplex rental property', true, false),
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
street_address = EXCLUDED.street_address,
|
|
||||||
updated_at = NOW();
|
|
||||||
|
|
||||||
-- Share residence 1 with user 3
|
-- Jane's properties (user 3)
|
||||||
|
(4, NOW() - INTERVAL '5 months', NOW(), 3, 2, 'Downtown Loft', '100 City Center', 'Unit 15B', 'Los Angeles', 'CA', '90012', 'USA', 2, 2, 1200, 2018, 'Modern loft in downtown LA', true, true),
|
||||||
|
|
||||||
|
-- Bob's properties (user 4)
|
||||||
|
(5, NOW() - INTERVAL '4 months', NOW(), 4, 3, 'Mountain View Condo', '321 Peak Drive', 'Unit 8', 'Denver', 'CO', '80202', 'USA', 2, 2, 1500, 2015, 'Ski-in/ski-out condo', true, true),
|
||||||
|
(6, NOW() - INTERVAL '3 months', NOW(), 4, 4, 'Suburban Townhouse', '555 Maple Lane', NULL, 'Aurora', 'CO', '80010', 'USA', 3, 3, 1900, 2008, 'End-unit townhouse with garage', true, false),
|
||||||
|
(7, NOW() - INTERVAL '2 months', NOW(), 4, 1, 'Rental House', '777 Income Ave', NULL, 'Boulder', 'CO', '80301', 'USA', 3, 2, 1600, 1990, 'Single family rental', true, false),
|
||||||
|
|
||||||
|
-- Alice's properties (user 5)
|
||||||
|
(8, NOW() - INTERVAL '3 months', NOW(), 5, 1, 'Craftsman Bungalow', '888 Artisan Way', NULL, 'Portland', 'OR', '97201', 'USA', 3, 2, 1700, 1925, 'Historic craftsman with modern updates', true, true),
|
||||||
|
(9, NOW() - INTERVAL '2 months', NOW(), 5, 2, 'River View Apartment', '999 Waterfront Dr', 'Apt 22', 'Portland', 'OR', '97209', 'USA', 1, 1, 750, 2020, 'Luxury apartment with river views', true, false),
|
||||||
|
|
||||||
|
-- Charlie's property (user 6)
|
||||||
|
(10, NOW() - INTERVAL '2 months', NOW(), 6, 5, 'Lakeside Mobile', '111 Lakeshore Park', 'Lot 45', 'Austin', 'TX', '78701', 'USA', 2, 1, 900, 2005, 'Mobile home in lakeside community', true, true),
|
||||||
|
|
||||||
|
-- Edward's property (user 8)
|
||||||
|
(11, NOW() - INTERVAL '2 weeks', NOW(), 8, 1, 'Fixer Upper', '222 Project Street', NULL, 'Seattle', 'WA', '98101', 'USA', 3, 1, 1400, 1970, 'Renovation project in progress', true, true),
|
||||||
|
|
||||||
|
-- Fiona's property (user 9)
|
||||||
|
(12, NOW() - INTERVAL '1 week', NOW(), 9, 2, 'Designer Studio', '333 Arts District', 'Studio 7', 'San Francisco', 'CA', '94102', 'USA', 0, 1, 600, 2022, 'Modern studio in arts district', true, true),
|
||||||
|
|
||||||
|
-- George's property (user 12)
|
||||||
|
(13, NOW() - INTERVAL '45 days', NOW(), 12, 4, 'Quiet Townhome', '444 Suburban Way', NULL, 'Scottsdale', 'AZ', '85251', 'USA', 3, 3, 2000, 2012, 'Family-friendly townhome', true, true),
|
||||||
|
|
||||||
|
-- Inactive residence
|
||||||
|
(14, NOW() - INTERVAL '1 year', NOW(), 4, 1, 'Sold Property', '666 Former Home', NULL, 'Phoenix', 'AZ', '85001', 'USA', 2, 1, 1100, 1980, 'Previously owned', false, false)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = NOW();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- RESIDENCE SHARING (multi-user access)
|
||||||
|
-- =====================================================
|
||||||
INSERT INTO residence_residence_users (residence_id, user_id)
|
INSERT INTO residence_residence_users (residence_id, user_id)
|
||||||
VALUES (1, 3)
|
VALUES
|
||||||
|
(1, 3), -- Jane has access to John's main home
|
||||||
|
(1, 5), -- Alice has access to John's main home
|
||||||
|
(2, 3), -- Jane has access to Beach house
|
||||||
|
(5, 12), -- George has access to Bob's condo
|
||||||
|
(8, 9) -- Fiona has access to Alice's bungalow
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Test Contractors
|
-- =====================================================
|
||||||
INSERT INTO task_contractor (id, created_at, updated_at, residence_id, created_by_id, name, company, phone, email, website, notes, is_favorite, is_active)
|
-- CONTRACTORS (all specialties, various ratings and details)
|
||||||
|
-- Specialties: 1=Plumber, 2=Electrician, 3=HVAC, 4=Handyman, 5=Roofer, 6=Painter, 7=Landscaper, 8=Carpenter, 9=Appliance Repair, 10=Locksmith
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO task_contractor (id, created_at, updated_at, residence_id, created_by_id, name, company, phone, email, website, notes, street_address, city, state_province, postal_code, rating, is_favorite, is_active)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW(), NOW(), 1, 2, 'Mike the Plumber', 'Mike''s Plumbing Co.', '+1-555-1001', 'mike@plumbing.com', 'https://mikesplumbing.com', 'Great service, always on time', true, true),
|
-- John's contractors (residence 1)
|
||||||
(2, NOW(), NOW(), 1, 2, 'Sparky Electric', 'Sparky Electrical Services', '+1-555-1002', 'info@sparky.com', NULL, 'Licensed and insured', false, true),
|
(1, NOW(), NOW(), 1, 2, 'Mike Johnson', 'Mike''s Plumbing Pro', '+1-555-1001', 'mike@plumbingpro.com', 'https://mikesplumbingpro.com', 'Best plumber in town. Always on time. 24/7 emergency service available.', '100 Trade Center', 'Springfield', 'IL', '62702', 4.9, true, true),
|
||||||
(3, NOW(), NOW(), 1, 2, 'Cool Air HVAC', 'Cool Air Heating & Cooling', '+1-555-1003', 'service@coolair.com', 'https://coolair.com', '24/7 emergency service', true, true),
|
(2, NOW(), NOW(), 1, 2, 'Sarah Electric', 'Sparky Electrical Services', '+1-555-1002', 'sarah@sparkyelectric.com', 'https://sparkyelectric.com', 'Licensed master electrician. Specializes in panel upgrades.', '200 Industrial Blvd', 'Springfield', 'IL', '62703', 4.7, false, true),
|
||||||
(4, NOW(), NOW(), 3, 3, 'Handy Andy', NULL, '+1-555-1004', 'andy@handyman.com', NULL, 'General repairs', false, true)
|
(3, NOW(), NOW(), 1, 2, 'Cool Air Team', 'Arctic Comfort HVAC', '+1-555-1003', 'service@arcticcomfort.com', 'https://arcticcomfort.com', 'Annual maintenance contract available. Fast response time.', '300 Climate Way', 'Springfield', 'IL', '62704', 4.8, true, true),
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
(4, NOW(), NOW(), 1, 2, 'Tom the Handyman', NULL, '+1-555-1004', 'tom.handyman@gmail.com', NULL, 'Jack of all trades. Great for small jobs.', NULL, 'Springfield', 'IL', NULL, 4.5, false, true),
|
||||||
name = EXCLUDED.name,
|
(5, NOW(), NOW(), 1, 2, 'Green Thumb Landscaping', 'Green Thumb LLC', '+1-555-1005', 'info@greenthumbland.com', 'https://greenthumbland.com', 'Weekly lawn service and seasonal plantings.', '400 Garden Path', 'Springfield', 'IL', '62705', 4.6, true, true),
|
||||||
company = EXCLUDED.company,
|
|
||||||
updated_at = NOW();
|
-- Beach house contractors (residence 2)
|
||||||
|
(6, NOW(), NOW(), 2, 2, 'Beach Plumbing Co', 'Coastal Plumbing', '+1-305-2001', 'coastal@plumbing.com', NULL, 'Specializes in salt-water corrosion issues.', '500 Shore Dr', 'Miami Beach', 'FL', '33140', 4.4, false, true),
|
||||||
|
(7, NOW(), NOW(), 2, 2, 'Hurricane Shutters Pro', 'Storm Ready Inc', '+1-305-2002', 'ready@stormready.com', 'https://stormready.com', 'Hurricane prep and shutter installation.', '600 Storm Lane', 'Miami', 'FL', '33141', 4.9, true, true),
|
||||||
|
|
||||||
|
-- Jane's contractors (residence 4)
|
||||||
|
(8, NOW(), NOW(), 4, 3, 'LA Maintenance', 'Downtown Services LLC', '+1-213-3001', 'support@dtservices.com', NULL, 'Building-approved contractor for condo repairs.', '700 Commerce St', 'Los Angeles', 'CA', '90013', 4.3, false, true),
|
||||||
|
(9, NOW(), NOW(), 4, 3, 'Appliance Doctor', 'Fix-It Appliances', '+1-213-3002', 'fixit@appliances.com', 'https://fixitappliances.com', 'All major brands. Same-day service available.', '800 Repair Rd', 'Los Angeles', 'CA', '90014', 4.7, true, true),
|
||||||
|
|
||||||
|
-- Bob's contractors (residences 5, 6, 7)
|
||||||
|
(10, NOW(), NOW(), 5, 4, 'Mountain Roof Experts', 'Peak Roofing', '+1-303-4001', 'peak@roofing.com', 'https://peakroofing.com', 'Snow load specialists. Emergency repairs.', '900 Summit Ave', 'Denver', 'CO', '80203', 4.8, true, true),
|
||||||
|
(11, NOW(), NOW(), 5, 4, 'Colorado Paint Crew', 'Altitude Painters', '+1-303-4002', 'paint@altitudepainters.com', NULL, 'Interior and exterior. Color consultation included.', '1000 Brush St', 'Denver', 'CO', '80204', 4.5, false, true),
|
||||||
|
(12, NOW(), NOW(), 6, 4, 'Garage Door Kings', 'Royal Doors Inc', '+1-303-4003', 'royal@doorsking.com', 'https://royaldoors.com', 'Installation and repair of all garage door types.', '1100 Entry Way', 'Aurora', 'CO', '80011', 4.6, false, true),
|
||||||
|
|
||||||
|
-- Alice's contractors (residences 8, 9)
|
||||||
|
(13, NOW(), NOW(), 8, 5, 'Historic Home Specialist', 'Preservation Builders', '+1-503-5001', 'info@preservationbuilders.com', 'https://preservationbuilders.com', 'Specializes in historic home restoration and repairs.', '1200 Heritage Ln', 'Portland', 'OR', '97202', 4.9, true, true),
|
||||||
|
(14, NOW(), NOW(), 8, 5, 'Eco Plumbing', 'Green Flow Plumbing', '+1-503-5002', 'green@flowplumbing.com', NULL, 'Eco-friendly solutions. Water conservation experts.', '1300 Eco Way', 'Portland', 'OR', '97203', 4.4, false, true),
|
||||||
|
|
||||||
|
-- Edward's contractors (residence 11)
|
||||||
|
(15, NOW(), NOW(), 11, 8, 'Demo Dave', 'Demolition & Renovation', '+1-206-6001', 'dave@demoreno.com', NULL, 'Demolition and structural work for renovations.', '1400 Build St', 'Seattle', 'WA', '98102', 4.2, false, true),
|
||||||
|
(16, NOW(), NOW(), 11, 8, 'Master Carpenter', 'Woodworks Plus', '+1-206-6002', 'craft@woodworksplus.com', 'https://woodworksplus.com', 'Custom cabinetry and finish carpentry.', '1500 Timber Rd', 'Seattle', 'WA', '98103', 4.8, true, true),
|
||||||
|
|
||||||
|
-- Fiona's contractors (residence 12)
|
||||||
|
(17, NOW(), NOW(), 12, 9, 'Smart Home Installer', 'Tech Home Solutions', '+1-415-7001', 'smart@techhome.com', 'https://techhome.com', 'Smart home device installation and setup.', '1600 Digital Dr', 'San Francisco', 'CA', '94103', 4.6, true, true),
|
||||||
|
|
||||||
|
-- George's contractors (residence 13)
|
||||||
|
(18, NOW(), NOW(), 13, 12, 'Desert Cooling', 'Cactus HVAC', '+1-480-8001', 'cool@cactushvac.com', 'https://cactushvac.com', 'Experts in desert climate cooling systems.', '1700 Heat Wave Ave', 'Scottsdale', 'AZ', '85252', 4.7, true, true),
|
||||||
|
(19, NOW(), NOW(), 13, 12, 'Pool Pro AZ', 'Aqua Services', '+1-480-8002', 'pool@aquaservices.com', NULL, 'Pool maintenance and equipment repair.', '1800 Splash Ln', 'Scottsdale', 'AZ', '85253', 4.5, false, true),
|
||||||
|
|
||||||
|
-- Inactive contractor
|
||||||
|
(20, NOW(), NOW(), 1, 2, 'Retired Roofer', 'Old Roof Co', '+1-555-9999', NULL, NULL, 'No longer in business', NULL, 'Springfield', 'IL', NULL, 3.5, false, false)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = NOW();
|
||||||
|
|
||||||
-- Contractor Specialties (many-to-many)
|
-- Contractor Specialties (many-to-many)
|
||||||
INSERT INTO task_contractor_specialties (contractor_id, contractor_specialty_id)
|
INSERT INTO task_contractor_specialties (contractor_id, contractor_specialty_id)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 1), -- Mike: Plumber
|
(1, 1), -- Mike: Plumber
|
||||||
(2, 2), -- Sparky: Electrician
|
(2, 2), -- Sarah: Electrician
|
||||||
(3, 3), -- Cool Air: HVAC
|
(3, 3), -- Cool Air: HVAC
|
||||||
(4, 4), -- Andy: Handyman
|
(4, 4), (4, 6), (4, 8), -- Tom: Handyman, Painter, Carpenter
|
||||||
(4, 6) -- Andy: Also Painter
|
(5, 7), -- Green Thumb: Landscaper
|
||||||
|
(6, 1), -- Beach Plumbing: Plumber
|
||||||
|
(7, 4), (7, 8), -- Hurricane: Handyman, Carpenter
|
||||||
|
(8, 4), (8, 1), (8, 2), -- LA Maintenance: Handyman, Plumber, Electrician
|
||||||
|
(9, 9), -- Appliance Doctor: Appliance Repair
|
||||||
|
(10, 5), -- Mountain Roof: Roofer
|
||||||
|
(11, 6), -- Colorado Paint: Painter
|
||||||
|
(12, 4), (12, 8), -- Garage Door: Handyman, Carpenter
|
||||||
|
(13, 8), (13, 6), -- Historic: Carpenter, Painter
|
||||||
|
(14, 1), -- Eco Plumbing: Plumber
|
||||||
|
(15, 4), (15, 8), -- Demo Dave: Handyman, Carpenter
|
||||||
|
(16, 8), -- Master Carpenter: Carpenter
|
||||||
|
(17, 2), (17, 4), -- Smart Home: Electrician, Handyman
|
||||||
|
(18, 3), -- Desert Cooling: HVAC
|
||||||
|
(19, 4), -- Pool Pro: Handyman
|
||||||
|
(20, 5) -- Retired Roofer: Roofer
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Test Tasks
|
-- =====================================================
|
||||||
|
-- TASKS (comprehensive coverage of all categories, priorities, statuses, frequencies)
|
||||||
|
-- Categories: 1=Plumbing, 2=Electrical, 3=HVAC, 4=Appliances, 5=Outdoor/Landscaping, 6=Structural, 7=Safety, 8=Cleaning, 9=Pest Control, 10=Other
|
||||||
|
-- Priorities: 1=Low, 2=Medium, 3=High, 4=Urgent
|
||||||
|
-- Statuses: 1=Pending, 2=In Progress, 3=Completed, 4=On Hold, 5=Cancelled
|
||||||
|
-- Frequencies: 1=Once, 2=Daily, 3=Weekly, 4=Bi-Weekly, 5=Monthly, 6=Quarterly, 7=Semi-Annual, 8=Annual
|
||||||
|
-- =====================================================
|
||||||
INSERT INTO task_task (id, created_at, updated_at, residence_id, created_by_id, assigned_to_id, title, description, category_id, priority_id, status_id, frequency_id, due_date, estimated_cost, contractor_id, is_cancelled, is_archived)
|
INSERT INTO task_task (id, created_at, updated_at, residence_id, created_by_id, assigned_to_id, title, description, category_id, priority_id, status_id, frequency_id, due_date, estimated_cost, contractor_id, is_cancelled, is_archived)
|
||||||
VALUES
|
VALUES
|
||||||
-- Residence 1 tasks
|
-- ===== RESIDENCE 1 (John's Main Home) - 15 tasks =====
|
||||||
(1, NOW(), NOW(), 1, 2, 2, 'Fix leaky faucet', 'Kitchen faucet is dripping', 1, 2, 1, 1, CURRENT_DATE + INTERVAL '7 days', 150.00, 1, false, false),
|
-- Plumbing tasks
|
||||||
(2, NOW(), NOW(), 1, 2, NULL, 'Replace smoke detector batteries', 'Annual battery replacement', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '30 days', 25.00, NULL, false, false),
|
(1, NOW() - INTERVAL '30 days', NOW(), 1, 2, 2, 'Fix leaky kitchen faucet', 'Kitchen faucet has been dripping for a week. Washer may need replacement.', 1, 2, 1, 1, CURRENT_DATE + INTERVAL '7 days', 150.00, 1, false, false),
|
||||||
(3, NOW(), NOW(), 1, 2, 2, 'HVAC filter replacement', 'Replace air filters', 3, 2, 2, 5, CURRENT_DATE + INTERVAL '14 days', 50.00, 3, false, false),
|
(2, NOW() - INTERVAL '60 days', NOW(), 1, 2, 3, 'Unclog bathroom drain', 'Master bathroom sink draining slowly. Tried Drano with no success.', 1, 3, 3, 1, CURRENT_DATE - INTERVAL '45 days', 85.00, 1, false, false),
|
||||||
(4, NOW(), NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn maintenance', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '2 days', NULL, NULL, false, false),
|
(3, NOW() - INTERVAL '15 days', NOW(), 1, 2, NULL, 'Water heater inspection', 'Annual inspection and flush of water heater tank', 1, 1, 1, 8, CURRENT_DATE + INTERVAL '30 days', 175.00, 1, false, false),
|
||||||
(5, NOW(), NOW(), 1, 2, NULL, 'Clean gutters', 'Remove leaves and debris', 5, 2, 1, 7, CURRENT_DATE + INTERVAL '60 days', 200.00, NULL, false, false),
|
|
||||||
|
|
||||||
-- Residence 2 tasks
|
-- Electrical tasks
|
||||||
(6, NOW(), NOW(), 2, 2, 2, 'Check pool chemicals', 'Test and balance pool water', 10, 2, 1, 3, CURRENT_DATE + INTERVAL '3 days', NULL, NULL, false, false),
|
(4, NOW() - INTERVAL '20 days', NOW(), 1, 2, 2, 'Install ceiling fan', 'Replace light fixture in master bedroom with ceiling fan', 2, 2, 2, 1, CURRENT_DATE + INTERVAL '14 days', 350.00, 2, false, false),
|
||||||
(7, NOW(), NOW(), 2, 2, NULL, 'Hurricane shutters inspection', 'Annual inspection before hurricane season', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '90 days', NULL, NULL, false, false),
|
(5, NOW() - INTERVAL '90 days', NOW(), 1, 2, NULL, 'Replace smoke detector batteries', 'Replace batteries in all 6 smoke detectors', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '60 days', 30.00, NULL, false, false),
|
||||||
|
(6, NOW() - INTERVAL '5 days', NOW(), 1, 2, NULL, 'Fix flickering lights', 'Living room lights flicker occasionally. May need new switch.', 2, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', 125.00, 2, false, false),
|
||||||
|
|
||||||
-- Residence 3 tasks
|
-- HVAC tasks
|
||||||
(8, NOW(), NOW(), 3, 3, 3, 'Fix garbage disposal', 'Disposal is jammed', 4, 3, 1, 1, CURRENT_DATE + INTERVAL '2 days', 100.00, NULL, false, false),
|
(7, NOW() - INTERVAL '45 days', NOW(), 1, 2, 2, 'Replace HVAC filters', 'Monthly filter replacement for central air system', 3, 2, 3, 5, CURRENT_DATE - INTERVAL '15 days', 45.00, 3, false, false),
|
||||||
(9, NOW(), NOW(), 3, 3, NULL, 'Deep clean apartment', 'Quarterly deep cleaning', 8, 1, 1, 6, CURRENT_DATE + INTERVAL '45 days', 300.00, NULL, false, false),
|
(8, NOW() - INTERVAL '10 days', NOW(), 1, 2, NULL, 'Schedule AC tune-up', 'Pre-summer AC maintenance and coolant check', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '60 days', 200.00, 3, false, false),
|
||||||
|
|
||||||
-- Residence 4 tasks
|
-- Outdoor/Landscaping tasks
|
||||||
(10, NOW(), NOW(), 4, 4, 4, 'Winterize pipes', 'Prepare plumbing for winter', 1, 3, 1, 8, CURRENT_DATE + INTERVAL '120 days', 250.00, NULL, false, false)
|
(9, NOW() - INTERVAL '7 days', NOW(), 1, 2, 3, 'Mow lawn', 'Weekly lawn mowing and edging', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '2 days', 50.00, 5, false, false),
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
(10, NOW() - INTERVAL '25 days', NOW(), 1, 2, NULL, 'Trim hedges', 'Trim front and back yard hedges', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '20 days', 150.00, 5, false, false),
|
||||||
title = EXCLUDED.title,
|
(11, NOW() - INTERVAL '100 days', NOW(), 1, 2, 5, 'Clean gutters', 'Remove leaves and debris from all gutters', 5, 2, 3, 7, CURRENT_DATE - INTERVAL '70 days', 175.00, NULL, false, false),
|
||||||
description = EXCLUDED.description,
|
(12, NOW() - INTERVAL '3 days', NOW(), 1, 2, NULL, 'Fertilize lawn', 'Apply spring fertilizer treatment', 5, 1, 1, 6, CURRENT_DATE + INTERVAL '5 days', 75.00, 5, false, false),
|
||||||
updated_at = NOW();
|
|
||||||
|
|
||||||
-- Test Task Completions
|
-- Safety tasks
|
||||||
INSERT INTO task_taskcompletion (id, created_at, updated_at, task_id, completed_by_id, completed_at, notes, actual_cost)
|
(13, NOW() - INTERVAL '180 days', NOW(), 1, 2, 2, 'Test fire extinguishers', 'Annual inspection of all fire extinguishers', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '150 days', 0.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- Cleaning tasks
|
||||||
|
(14, NOW() - INTERVAL '40 days', NOW(), 1, 2, 3, 'Deep clean carpets', 'Professional carpet cleaning for all rooms', 8, 1, 3, 8, CURRENT_DATE - INTERVAL '25 days', 350.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- On hold task
|
||||||
|
(15, NOW() - INTERVAL '50 days', NOW(), 1, 2, NULL, 'Paint exterior', 'Repaint exterior trim and shutters', 6, 2, 4, 1, CURRENT_DATE + INTERVAL '90 days', 2500.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 2 (Beach House) - 8 tasks =====
|
||||||
|
(16, NOW() - INTERVAL '20 days', NOW(), 2, 2, 2, 'Check pool chemicals', 'Weekly pool water testing and chemical balance', 10, 2, 3, 3, CURRENT_DATE - INTERVAL '6 days', 50.00, NULL, false, false),
|
||||||
|
(17, NOW() - INTERVAL '15 days', NOW(), 2, 2, NULL, 'Hurricane shutter inspection', 'Annual inspection before hurricane season', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '45 days', 300.00, 7, false, false),
|
||||||
|
(18, NOW() - INTERVAL '30 days', NOW(), 2, 2, NULL, 'AC filter change', 'Replace AC filters - salt air requires more frequent changes', 3, 2, 1, 5, CURRENT_DATE + INTERVAL '5 days', 60.00, NULL, false, false),
|
||||||
|
(19, NOW() - INTERVAL '60 days', NOW(), 2, 2, 2, 'Fix outdoor shower', 'Outdoor shower has low pressure', 1, 2, 3, 1, CURRENT_DATE - INTERVAL '40 days', 200.00, 6, false, false),
|
||||||
|
(20, NOW() - INTERVAL '5 days', NOW(), 2, 2, NULL, 'Pressure wash deck', 'Clean salt buildup from deck and railings', 8, 1, 1, 6, CURRENT_DATE + INTERVAL '30 days', 250.00, NULL, false, false),
|
||||||
|
(21, NOW() - INTERVAL '90 days', NOW(), 2, 2, NULL, 'Pest control treatment', 'Quarterly pest control for beach property', 9, 2, 3, 6, CURRENT_DATE - INTERVAL '60 days', 150.00, NULL, false, false),
|
||||||
|
(22, NOW() - INTERVAL '10 days', NOW(), 2, 2, NULL, 'Replace patio furniture', 'Sun-damaged furniture needs replacement', 10, 1, 4, 1, CURRENT_DATE + INTERVAL '60 days', 1200.00, NULL, false, false),
|
||||||
|
(23, NOW() - INTERVAL '180 days', NOW(), 2, 2, 2, 'Install security cameras', 'Add 4 outdoor security cameras', 7, 2, 3, 1, CURRENT_DATE - INTERVAL '150 days', 800.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 3 (Investment Duplex) - 5 tasks =====
|
||||||
|
(24, NOW() - INTERVAL '25 days', NOW(), 3, 2, NULL, 'Unit A - Repair drywall', 'Patch and paint drywall damage from tenant', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', 400.00, NULL, false, false),
|
||||||
|
(25, NOW() - INTERVAL '10 days', NOW(), 3, 2, NULL, 'Unit B - Replace dishwasher', 'Dishwasher not draining properly', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '3 days', 650.00, NULL, false, false),
|
||||||
|
(26, NOW() - INTERVAL '45 days', NOW(), 3, 2, 2, 'Annual fire inspection', 'Required annual fire safety inspection', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '30 days', 150.00, NULL, false, false),
|
||||||
|
(27, NOW() - INTERVAL '5 days', NOW(), 3, 2, NULL, 'Replace hallway carpet', 'Common area carpet showing wear', 6, 1, 1, 1, CURRENT_DATE + INTERVAL '45 days', 1500.00, NULL, false, false),
|
||||||
|
(28, NOW() - INTERVAL '200 days', NOW(), 3, 2, NULL, 'Roof inspection', 'Annual roof inspection', 6, 2, 3, 8, CURRENT_DATE - INTERVAL '170 days', 250.00, NULL, false, true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 4 (Jane's Downtown Loft) - 7 tasks =====
|
||||||
|
(29, NOW() - INTERVAL '15 days', NOW(), 4, 3, 3, 'Fix garbage disposal', 'Disposal is jammed and making noise', 4, 3, 2, 1, CURRENT_DATE + INTERVAL '2 days', 150.00, 8, false, false),
|
||||||
|
(30, NOW() - INTERVAL '30 days', NOW(), 4, 3, NULL, 'Clean dryer vent', 'Annual dryer vent cleaning for fire safety', 7, 3, 1, 8, CURRENT_DATE + INTERVAL '30 days', 125.00, NULL, false, false),
|
||||||
|
(31, NOW() - INTERVAL '60 days', NOW(), 4, 3, 3, 'Touch up paint', 'Touch up scuff marks on walls', 6, 1, 3, 1, CURRENT_DATE - INTERVAL '45 days', 0.00, NULL, false, false),
|
||||||
|
(32, NOW() - INTERVAL '7 days', NOW(), 4, 3, NULL, 'Replace refrigerator water filter', 'Bi-annual filter replacement', 4, 1, 1, 7, CURRENT_DATE + INTERVAL '14 days', 50.00, 9, false, false),
|
||||||
|
(33, NOW() - INTERVAL '90 days', NOW(), 4, 3, NULL, 'Deep clean oven', 'Professional oven cleaning', 8, 1, 3, 8, CURRENT_DATE - INTERVAL '60 days', 100.00, NULL, false, false),
|
||||||
|
(34, NOW() - INTERVAL '120 days', NOW(), 4, 3, 3, 'Install smart thermostat', 'Replace old thermostat with Nest', 3, 2, 3, 1, CURRENT_DATE - INTERVAL '100 days', 250.00, NULL, false, false),
|
||||||
|
(35, NOW() - INTERVAL '3 days', NOW(), 4, 3, NULL, 'Repair window blinds', 'Bedroom blinds cord is broken', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', 75.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 5 (Bob's Mountain Condo) - 6 tasks =====
|
||||||
|
(36, NOW() - INTERVAL '20 days', NOW(), 5, 4, 4, 'Winterize pipes', 'Prepare plumbing for winter freeze', 1, 3, 3, 8, CURRENT_DATE - INTERVAL '10 days', 200.00, NULL, false, false),
|
||||||
|
(37, NOW() - INTERVAL '40 days', NOW(), 5, 4, NULL, 'Check roof for snow damage', 'Inspect after heavy snowfall', 6, 3, 3, 1, CURRENT_DATE - INTERVAL '35 days', 0.00, 10, false, false),
|
||||||
|
(38, NOW() - INTERVAL '10 days', NOW(), 5, 4, 12, 'Tune ski equipment storage', 'Organize ski room and check equipment', 10, 1, 2, 8, CURRENT_DATE + INTERVAL '7 days', 0.00, NULL, false, false),
|
||||||
|
(39, NOW() - INTERVAL '5 days', NOW(), 5, 4, NULL, 'Replace entry door weatherstrip', 'Cold air leaking around front door', 6, 2, 1, 1, CURRENT_DATE + INTERVAL '10 days', 100.00, NULL, false, false),
|
||||||
|
(40, NOW() - INTERVAL '90 days', NOW(), 5, 4, 4, 'HOA deck staining', 'Required deck maintenance per HOA', 5, 2, 3, 8, CURRENT_DATE - INTERVAL '60 days', 500.00, NULL, false, false),
|
||||||
|
(41, NOW() - INTERVAL '180 days', NOW(), 5, 4, NULL, 'Fireplace inspection', 'Annual chimney and fireplace check', 7, 3, 3, 8, CURRENT_DATE - INTERVAL '150 days', 175.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 8 (Alice's Craftsman) - 6 tasks =====
|
||||||
|
(42, NOW() - INTERVAL '25 days', NOW(), 8, 5, 5, 'Refinish hardwood floors', 'Sand and refinish original hardwood in living room', 6, 2, 2, 1, CURRENT_DATE + INTERVAL '30 days', 2000.00, 13, false, false),
|
||||||
|
(43, NOW() - INTERVAL '50 days', NOW(), 8, 5, 9, 'Repair original windows', 'Restore and weatherproof historic windows', 6, 2, 3, 1, CURRENT_DATE - INTERVAL '20 days', 1500.00, 13, false, false),
|
||||||
|
(44, NOW() - INTERVAL '15 days', NOW(), 8, 5, NULL, 'Update knob and tube wiring', 'Replace section of old wiring in attic', 2, 4, 1, 1, CURRENT_DATE + INTERVAL '14 days', 3000.00, NULL, false, false),
|
||||||
|
(45, NOW() - INTERVAL '30 days', NOW(), 8, 5, 5, 'Garden maintenance', 'Weekly garden care and weeding', 5, 1, 3, 3, CURRENT_DATE - INTERVAL '7 days', 75.00, NULL, false, false),
|
||||||
|
(46, NOW() - INTERVAL '7 days', NOW(), 8, 5, NULL, 'Fix porch swing', 'Chains need tightening and wood needs oiling', 10, 1, 1, 1, CURRENT_DATE + INTERVAL '21 days', 50.00, 13, false, false),
|
||||||
|
(47, NOW() - INTERVAL '60 days', NOW(), 8, 5, 5, 'Clean rain gutters', 'Clear leaves from craftsman-style gutters', 5, 2, 3, 7, CURRENT_DATE - INTERVAL '30 days', 200.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 11 (Edward's Fixer Upper) - 5 tasks =====
|
||||||
|
(48, NOW() - INTERVAL '14 days', NOW(), 11, 8, 8, 'Demo bathroom tile', 'Remove old tile in master bathroom', 6, 3, 2, 1, CURRENT_DATE + INTERVAL '7 days', 500.00, 15, false, false),
|
||||||
|
(49, NOW() - INTERVAL '10 days', NOW(), 11, 8, NULL, 'Install new cabinets', 'Kitchen cabinet installation', 6, 3, 1, 1, CURRENT_DATE + INTERVAL '21 days', 5000.00, 16, false, false),
|
||||||
|
(50, NOW() - INTERVAL '7 days', NOW(), 11, 8, 8, 'Run new electrical', 'Additional outlets for kitchen', 2, 3, 2, 1, CURRENT_DATE + INTERVAL '14 days', 1200.00, NULL, false, false),
|
||||||
|
(51, NOW() - INTERVAL '3 days', NOW(), 11, 8, NULL, 'Plumbing rough-in', 'Rough plumbing for bathroom remodel', 1, 3, 1, 1, CURRENT_DATE + INTERVAL '10 days', 2500.00, NULL, false, false),
|
||||||
|
(52, NOW() - INTERVAL '1 day', NOW(), 11, 8, 8, 'Order new appliances', 'Select and order kitchen appliances', 4, 2, 2, 1, CURRENT_DATE + INTERVAL '5 days', 4000.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 12 (Fiona's Studio) - 4 tasks =====
|
||||||
|
(53, NOW() - INTERVAL '7 days', NOW(), 12, 9, 9, 'Setup smart lights', 'Install Philips Hue throughout studio', 2, 1, 3, 1, CURRENT_DATE - INTERVAL '3 days', 400.00, 17, false, false),
|
||||||
|
(54, NOW() - INTERVAL '5 days', NOW(), 12, 9, NULL, 'Mount TV on wall', 'Install TV mount and hide cables', 2, 1, 1, 1, CURRENT_DATE + INTERVAL '10 days', 200.00, 17, false, false),
|
||||||
|
(55, NOW() - INTERVAL '3 days', NOW(), 12, 9, 9, 'Organize closet', 'Install closet organization system', 10, 1, 2, 1, CURRENT_DATE + INTERVAL '7 days', 300.00, NULL, false, false),
|
||||||
|
(56, NOW() - INTERVAL '1 day', NOW(), 12, 9, NULL, 'Replace faucet aerator', 'Low water pressure in bathroom sink', 1, 1, 1, 1, CURRENT_DATE + INTERVAL '14 days', 15.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 13 (George's Townhome) - 5 tasks =====
|
||||||
|
(57, NOW() - INTERVAL '30 days', NOW(), 13, 12, 12, 'Service pool equipment', 'Quarterly pool pump and filter service', 10, 2, 3, 6, CURRENT_DATE - INTERVAL '15 days', 175.00, 19, false, false),
|
||||||
|
(58, NOW() - INTERVAL '20 days', NOW(), 13, 12, NULL, 'HVAC maintenance', 'Pre-summer AC tune-up', 3, 2, 1, 8, CURRENT_DATE + INTERVAL '30 days', 200.00, 18, false, false),
|
||||||
|
(59, NOW() - INTERVAL '10 days', NOW(), 13, 12, 12, 'Trim desert landscaping', 'Prune cacti and desert plants', 5, 1, 2, 6, CURRENT_DATE + INTERVAL '5 days', 100.00, NULL, false, false),
|
||||||
|
(60, NOW() - INTERVAL '45 days', NOW(), 13, 12, 12, 'Check irrigation system', 'Test all drip irrigation zones', 5, 2, 3, 5, CURRENT_DATE - INTERVAL '30 days', 50.00, NULL, false, false),
|
||||||
|
(61, NOW() - INTERVAL '5 days', NOW(), 13, 12, NULL, 'Replace garage door opener', 'Old opener failing', 4, 2, 1, 1, CURRENT_DATE + INTERVAL '14 days', 400.00, NULL, false, false),
|
||||||
|
|
||||||
|
-- Cancelled task
|
||||||
|
(62, NOW() - INTERVAL '60 days', NOW(), 1, 2, NULL, 'Build treehouse', 'Cancelled - tree not suitable', 6, 1, 5, 1, CURRENT_DATE - INTERVAL '30 days', 2000.00, NULL, true, false)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- TASK COMPLETIONS WITH IMAGES
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO task_taskcompletion (id, created_at, updated_at, task_id, completed_by_id, completed_at, notes, actual_cost, rating)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days', 4, 3, NOW() - INTERVAL '2 days', 'Lawn looks great!', NULL)
|
-- Completed tasks from above with various ratings and costs
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
(1, NOW() - INTERVAL '45 days', NOW(), 2, 3, NOW() - INTERVAL '45 days', 'Used drain snake and cleared hair clog. Recommend drain cover.', 75.00, 5),
|
||||||
notes = EXCLUDED.notes,
|
(2, NOW() - INTERVAL '60 days', NOW(), 5, 2, NOW() - INTERVAL '60 days', 'All 6 detectors updated with fresh 9V batteries.', 25.00, 4),
|
||||||
updated_at = NOW();
|
(3, NOW() - INTERVAL '15 days', NOW(), 7, 2, NOW() - INTERVAL '15 days', 'Replaced with MERV 13 filters. System running great.', 55.00, 5),
|
||||||
|
(4, NOW() - INTERVAL '2 days', NOW(), 9, 3, NOW() - INTERVAL '2 days', 'Lawn looks perfect. Edged along sidewalks and driveway.', 50.00, 5),
|
||||||
|
(5, NOW() - INTERVAL '70 days', NOW(), 11, 5, NOW() - INTERVAL '70 days', 'Removed significant leaf buildup. Found minor gutter damage on north side - will need repair next year.', 200.00, 4),
|
||||||
|
(6, NOW() - INTERVAL '150 days', NOW(), 13, 2, NOW() - INTERVAL '150 days', 'All extinguishers passed inspection. Kitchen one expires next year.', 0.00, 5),
|
||||||
|
(7, NOW() - INTERVAL '25 days', NOW(), 14, 3, NOW() - INTERVAL '25 days', 'Steam cleaned all carpets. Looks like new! Recommend annual cleaning.', 375.00, 5),
|
||||||
|
(8, NOW() - INTERVAL '6 days', NOW(), 16, 2, NOW() - INTERVAL '6 days', 'pH balanced. Added chlorine. Water crystal clear.', 45.00, 4),
|
||||||
|
(9, NOW() - INTERVAL '40 days', NOW(), 19, 2, NOW() - INTERVAL '40 days', 'Fixed low pressure issue - was a clogged showerhead. Replaced with rain showerhead.', 150.00, 5),
|
||||||
|
(10, NOW() - INTERVAL '60 days', NOW(), 21, 2, NOW() - INTERVAL '60 days', 'Interior and exterior treatment completed. No signs of pests.', 150.00, 4),
|
||||||
|
(11, NOW() - INTERVAL '150 days', NOW(), 23, 2, NOW() - INTERVAL '150 days', 'Installed 4 Arlo cameras. App configured and working great. Can view remotely.', 950.00, 5),
|
||||||
|
(12, NOW() - INTERVAL '30 days', NOW(), 26, 2, NOW() - INTERVAL '30 days', 'Passed inspection. All smoke detectors and fire extinguishers compliant.', 150.00, 5),
|
||||||
|
(13, NOW() - INTERVAL '170 days', NOW(), 28, 2, NOW() - INTERVAL '170 days', 'No damage found. Roof in good condition for another 5+ years.', 200.00, 4),
|
||||||
|
(14, NOW() - INTERVAL '45 days', NOW(), 31, 3, NOW() - INTERVAL '45 days', 'Touched up all scuffs in hallway and bedroom. Used matching Benjamin Moore paint.', 0.00, 5),
|
||||||
|
(15, NOW() - INTERVAL '60 days', NOW(), 33, 3, NOW() - INTERVAL '60 days', 'Oven sparkling clean. Removed years of baked-on grease.', 100.00, 4),
|
||||||
|
(16, NOW() - INTERVAL '100 days', NOW(), 34, 3, NOW() - INTERVAL '100 days', 'Nest installed and configured. Already seeing energy savings!', 275.00, 5),
|
||||||
|
(17, NOW() - INTERVAL '10 days', NOW(), 36, 4, NOW() - INTERVAL '10 days', 'All pipes insulated. Outdoor spigots covered. Ready for winter.', 225.00, 5),
|
||||||
|
(18, NOW() - INTERVAL '35 days', NOW(), 37, 4, NOW() - INTERVAL '35 days', 'No damage from snow. Cleared some accumulated ice from valleys.', 0.00, 4),
|
||||||
|
(19, NOW() - INTERVAL '60 days', NOW(), 40, 4, NOW() - INTERVAL '60 days', 'Applied two coats of deck stain. Looks beautiful. HOA approved.', 550.00, 5),
|
||||||
|
(20, NOW() - INTERVAL '150 days', NOW(), 41, 4, NOW() - INTERVAL '150 days', 'Chimney swept. Damper working properly. Ready for ski season!', 175.00, 5),
|
||||||
|
(21, NOW() - INTERVAL '20 days', NOW(), 43, 9, NOW() - INTERVAL '20 days', 'Beautiful restoration work. Windows operate smoothly and seal properly now.', 1650.00, 5),
|
||||||
|
(22, NOW() - INTERVAL '7 days', NOW(), 45, 5, NOW() - INTERVAL '7 days', 'Weeded all beds. Pruned roses. Garden looking great for spring.', 75.00, 4),
|
||||||
|
(23, NOW() - INTERVAL '30 days', NOW(), 47, 5, NOW() - INTERVAL '30 days', 'Cleared all gutters and downspouts. Historic copper gutters in good shape.', 225.00, 5),
|
||||||
|
(24, NOW() - INTERVAL '3 days', NOW(), 53, 9, NOW() - INTERVAL '3 days', 'All 12 Hue bulbs installed. Set up scenes for work, relax, and party modes!', 420.00, 5),
|
||||||
|
(25, NOW() - INTERVAL '15 days', NOW(), 57, 12, NOW() - INTERVAL '15 days', 'Pump running great. Filter cleaned. Water sparkling.', 175.00, 4),
|
||||||
|
(26, NOW() - INTERVAL '30 days', NOW(), 60, 12, NOW() - INTERVAL '30 days', 'All 8 zones tested. Replaced 3 emitters. System efficient.', 65.00, 5)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET notes = EXCLUDED.notes, updated_at = NOW();
|
||||||
|
|
||||||
-- Test Documents (using Go/GORM schema)
|
-- =====================================================
|
||||||
INSERT INTO task_document (id, created_at, updated_at, residence_id, created_by_id, title, description, document_type, file_url, file_name, purchase_date, expiry_date, purchase_price, vendor, serial_number, is_active)
|
-- TASK COMPLETION IMAGES
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO task_taskcompletionimage (id, created_at, updated_at, completion_id, image_url, caption)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW(), NOW(), 1, 2, 'HVAC Warranty', 'Warranty for central air system', 'warranty', '/uploads/docs/hvac_warranty.pdf', 'hvac_warranty.pdf', '2023-06-15', '2028-06-15', 5000.00, 'Cool Air HVAC', 'HVAC-2023-001', true),
|
-- Completion 1 (Drain unclog) - 2 images
|
||||||
(2, NOW(), NOW(), 1, 2, 'Home Insurance Policy', 'Annual home insurance', 'insurance', '/uploads/docs/insurance.pdf', 'insurance.pdf', '2024-01-01', '2025-01-01', 1200.00, 'State Farm', NULL, true),
|
(1, NOW() - INTERVAL '45 days', NOW(), 1, 'https://picsum.photos/seed/drain1/800/600', 'Before - slow draining sink'),
|
||||||
(3, NOW(), NOW(), 1, 2, 'Refrigerator Manual', 'User manual for kitchen fridge', 'manual', '/uploads/docs/fridge_manual.pdf', 'fridge_manual.pdf', '2022-03-20', NULL, 1500.00, 'Best Buy', 'LG-RF-2022', true),
|
(2, NOW() - INTERVAL '45 days', NOW(), 1, 'https://picsum.photos/seed/drain2/800/600', 'After - drain cleared and flowing'),
|
||||||
(4, NOW(), NOW(), 3, 3, 'Lease Agreement', 'Apartment lease contract', 'contract', '/uploads/docs/lease.pdf', 'lease.pdf', '2024-01-01', '2025-01-01', NULL, 'Downtown Properties LLC', NULL, true)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
updated_at = NOW();
|
|
||||||
|
|
||||||
-- Test Notifications
|
-- Completion 3 (HVAC filters) - 3 images
|
||||||
INSERT INTO notifications_notification (id, created_at, updated_at, user_id, notification_type, title, body, task_id, sent, read)
|
(3, NOW() - INTERVAL '15 days', NOW(), 3, 'https://picsum.photos/seed/filter1/800/600', 'Old dirty filter'),
|
||||||
|
(4, NOW() - INTERVAL '15 days', NOW(), 3, 'https://picsum.photos/seed/filter2/800/600', 'New MERV 13 filter installed'),
|
||||||
|
(5, NOW() - INTERVAL '15 days', NOW(), 3, 'https://picsum.photos/seed/filter3/800/600', 'HVAC system running'),
|
||||||
|
|
||||||
|
-- Completion 4 (Lawn mowing) - 4 images
|
||||||
|
(6, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn1/800/600', 'Front yard before'),
|
||||||
|
(7, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn2/800/600', 'Front yard after'),
|
||||||
|
(8, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn3/800/600', 'Back yard completed'),
|
||||||
|
(9, NOW() - INTERVAL '2 days', NOW(), 4, 'https://picsum.photos/seed/lawn4/800/600', 'Clean edging along driveway'),
|
||||||
|
|
||||||
|
-- Completion 5 (Gutter cleaning) - 2 images
|
||||||
|
(10, NOW() - INTERVAL '70 days', NOW(), 5, 'https://picsum.photos/seed/gutter1/800/600', 'Leaves removed from gutters'),
|
||||||
|
(11, NOW() - INTERVAL '70 days', NOW(), 5, 'https://picsum.photos/seed/gutter2/800/600', 'Minor damage found - north side'),
|
||||||
|
|
||||||
|
-- Completion 7 (Carpet cleaning) - 3 images
|
||||||
|
(12, NOW() - INTERVAL '25 days', NOW(), 7, 'https://picsum.photos/seed/carpet1/800/600', 'Living room before'),
|
||||||
|
(13, NOW() - INTERVAL '25 days', NOW(), 7, 'https://picsum.photos/seed/carpet2/800/600', 'Living room after'),
|
||||||
|
(14, NOW() - INTERVAL '25 days', NOW(), 7, 'https://picsum.photos/seed/carpet3/800/600', 'Bedroom carpet fresh'),
|
||||||
|
|
||||||
|
-- Completion 9 (Outdoor shower) - 2 images
|
||||||
|
(15, NOW() - INTERVAL '40 days', NOW(), 9, 'https://picsum.photos/seed/shower1/800/600', 'New rain showerhead installed'),
|
||||||
|
(16, NOW() - INTERVAL '40 days', NOW(), 9, 'https://picsum.photos/seed/shower2/800/600', 'Full water pressure restored'),
|
||||||
|
|
||||||
|
-- Completion 11 (Security cameras) - 4 images
|
||||||
|
(17, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam1/800/600', 'Front door camera'),
|
||||||
|
(18, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam2/800/600', 'Driveway camera'),
|
||||||
|
(19, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam3/800/600', 'Backyard camera'),
|
||||||
|
(20, NOW() - INTERVAL '150 days', NOW(), 11, 'https://picsum.photos/seed/cam4/800/600', 'Pool area camera'),
|
||||||
|
|
||||||
|
-- Completion 16 (Smart thermostat) - 2 images
|
||||||
|
(21, NOW() - INTERVAL '100 days', NOW(), 16, 'https://picsum.photos/seed/nest1/800/600', 'Old thermostat removed'),
|
||||||
|
(22, NOW() - INTERVAL '100 days', NOW(), 16, 'https://picsum.photos/seed/nest2/800/600', 'New Nest installed and configured'),
|
||||||
|
|
||||||
|
-- Completion 17 (Winterize pipes) - 3 images
|
||||||
|
(23, NOW() - INTERVAL '10 days', NOW(), 17, 'https://picsum.photos/seed/winter1/800/600', 'Pipe insulation applied'),
|
||||||
|
(24, NOW() - INTERVAL '10 days', NOW(), 17, 'https://picsum.photos/seed/winter2/800/600', 'Outdoor spigot cover'),
|
||||||
|
(25, NOW() - INTERVAL '10 days', NOW(), 17, 'https://picsum.photos/seed/winter3/800/600', 'Heat tape on exposed pipes'),
|
||||||
|
|
||||||
|
-- Completion 19 (Deck staining) - 4 images
|
||||||
|
(26, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck1/800/600', 'Deck before staining'),
|
||||||
|
(27, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck2/800/600', 'First coat applied'),
|
||||||
|
(28, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck3/800/600', 'Second coat complete'),
|
||||||
|
(29, NOW() - INTERVAL '60 days', NOW(), 19, 'https://picsum.photos/seed/deck4/800/600', 'Final result - beautiful!'),
|
||||||
|
|
||||||
|
-- Completion 21 (Window restoration) - 3 images
|
||||||
|
(30, NOW() - INTERVAL '20 days', NOW(), 21, 'https://picsum.photos/seed/window1/800/600', 'Original window before'),
|
||||||
|
(31, NOW() - INTERVAL '20 days', NOW(), 21, 'https://picsum.photos/seed/window2/800/600', 'Restoration in progress'),
|
||||||
|
(32, NOW() - INTERVAL '20 days', NOW(), 21, 'https://picsum.photos/seed/window3/800/600', 'Restored window - good as new'),
|
||||||
|
|
||||||
|
-- Completion 24 (Smart lights) - 2 images
|
||||||
|
(33, NOW() - INTERVAL '3 days', NOW(), 24, 'https://picsum.photos/seed/hue1/800/600', 'Hue lights in work mode'),
|
||||||
|
(34, NOW() - INTERVAL '3 days', NOW(), 24, 'https://picsum.photos/seed/hue2/800/600', 'Hue lights in relax mode')
|
||||||
|
ON CONFLICT (id) DO UPDATE SET image_url = EXCLUDED.image_url, caption = EXCLUDED.caption;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- DOCUMENTS WITH IMAGES (all document types)
|
||||||
|
-- Document types: general, warranty, receipt, contract, insurance, manual
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO task_document (id, created_at, updated_at, residence_id, created_by_id, task_id, title, description, document_type, file_url, file_name, file_size, mime_type, purchase_date, expiry_date, purchase_price, vendor, serial_number, model_number, is_active)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day', 2, 'task_due_soon', 'Task Due Soon', 'Fix leaky faucet is due in 7 days', 1, true, false),
|
-- ===== RESIDENCE 1 - Warranties and documents =====
|
||||||
(2, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days', 2, 'task_completed', 'Task Completed', 'Mow lawn has been marked as completed', 4, true, true),
|
(1, NOW() - INTERVAL '6 months', NOW(), 1, 2, NULL, 'HVAC System Warranty', '5-year warranty on Carrier central air system', 'warranty', 'https://example.com/docs/hvac_warranty.pdf', 'hvac_warranty.pdf', 245000, 'application/pdf', '2023-06-15', '2028-06-15', 5500.00, 'Arctic Comfort HVAC', 'CARRIER-2023-45892', '24ACC636A003', true),
|
||||||
(3, NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days', 3, 'task_assigned', 'Task Assigned', 'You have been assigned to: Mow lawn', 4, true, true)
|
(2, NOW() - INTERVAL '5 months', NOW(), 1, 2, NULL, 'Water Heater Warranty', '6-year warranty on Rheem water heater', 'warranty', 'https://example.com/docs/water_heater.pdf', 'water_heater_warranty.pdf', 189000, 'application/pdf', '2022-03-10', '2028-03-10', 1200.00, 'Home Depot', 'RHEEM-22-78456', 'PROG50-38N RH67', true),
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
(3, NOW() - INTERVAL '4 months', NOW(), 1, 2, NULL, 'Refrigerator Warranty', '2-year manufacturer warranty on LG refrigerator', 'warranty', 'https://example.com/docs/fridge_warranty.pdf', 'lg_fridge_warranty.pdf', 156000, 'application/pdf', '2024-01-20', '2026-01-20', 2800.00, 'Best Buy', 'LG-RF-2024-11234', 'LRMVS3006S', true),
|
||||||
title = EXCLUDED.title,
|
(4, NOW() - INTERVAL '3 months', NOW(), 1, 2, NULL, 'Dishwasher Warranty', '1-year warranty - expiring soon!', 'warranty', 'https://example.com/docs/dishwasher.pdf', 'bosch_dishwasher.pdf', 134000, 'application/pdf', '2023-08-05', '2024-08-05', 950.00, 'Lowes', 'BOSCH-DW-89012', 'SHPM88Z75N', true),
|
||||||
body = EXCLUDED.body,
|
(5, NOW() - INTERVAL '1 year', NOW(), 1, 2, NULL, 'Home Insurance Policy', 'Annual homeowners insurance - State Farm', 'insurance', 'https://example.com/docs/insurance_2024.pdf', 'home_insurance_2024.pdf', 890000, 'application/pdf', '2024-01-01', '2025-01-01', 1450.00, 'State Farm', NULL, NULL, true),
|
||||||
updated_at = NOW();
|
(6, NOW() - INTERVAL '6 months', NOW(), 1, 2, NULL, 'Refrigerator User Manual', 'LG French Door Refrigerator manual', 'manual', 'https://example.com/docs/lg_manual.pdf', 'lg_fridge_manual.pdf', 4500000, 'application/pdf', NULL, NULL, NULL, 'LG Electronics', NULL, 'LRMVS3006S', true),
|
||||||
|
(7, NOW() - INTERVAL '2 months', NOW(), 1, 2, 7, 'HVAC Filter Receipt', 'Receipt for MERV 13 filters', 'receipt', 'https://example.com/docs/filter_receipt.pdf', 'hvac_filters_receipt.pdf', 45000, 'application/pdf', '2024-10-15', NULL, 55.00, 'Amazon', NULL, NULL, true),
|
||||||
|
(8, NOW() - INTERVAL '3 years', NOW(), 1, 2, NULL, 'Roof Inspection Report', 'Annual roof inspection certificate', 'general', 'https://example.com/docs/roof_inspection.pdf', 'roof_inspection_2024.pdf', 320000, 'application/pdf', '2024-04-20', NULL, 250.00, 'Peak Roofing', NULL, NULL, true),
|
||||||
|
|
||||||
-- Notification Preferences
|
-- ===== RESIDENCE 2 - Beach house documents =====
|
||||||
|
(9, NOW() - INTERVAL '4 months', NOW(), 2, 2, NULL, 'Hurricane Shutters Warranty', '10-year warranty on accordion shutters', 'warranty', 'https://example.com/docs/shutters_warranty.pdf', 'hurricane_shutters.pdf', 278000, 'application/pdf', '2023-05-01', '2033-05-01', 8500.00, 'Storm Ready Inc', 'STORM-2023-456', 'ACC-200', true),
|
||||||
|
(10, NOW() - INTERVAL '3 months', NOW(), 2, 2, NULL, 'Pool Equipment Warranty', '3-year warranty on pool pump and filter', 'warranty', 'https://example.com/docs/pool_warranty.pdf', 'pool_equipment_warranty.pdf', 167000, 'application/pdf', '2024-02-15', '2027-02-15', 2200.00, 'Pool Supply World', 'PSW-PUMP-78123', 'Pentair 342001', true),
|
||||||
|
(11, NOW() - INTERVAL '6 months', NOW(), 2, 2, 23, 'Security Camera Receipt', 'Arlo Pro 4 cameras purchase receipt', 'receipt', 'https://example.com/docs/camera_receipt.pdf', 'arlo_cameras_receipt.pdf', 89000, 'application/pdf', '2024-07-10', NULL, 950.00, 'Amazon', NULL, 'VMC4050P', true),
|
||||||
|
(12, NOW() - INTERVAL '1 year', NOW(), 2, 2, NULL, 'Flood Insurance Policy', 'FEMA flood insurance for coastal property', 'insurance', 'https://example.com/docs/flood_insurance.pdf', 'flood_insurance_2024.pdf', 456000, 'application/pdf', '2024-06-01', '2025-06-01', 2800.00, 'FEMA NFIP', NULL, NULL, true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 3 - Investment duplex =====
|
||||||
|
(13, NOW() - INTERVAL '5 months', NOW(), 3, 2, NULL, 'Rental Property Insurance', 'Landlord insurance policy', 'insurance', 'https://example.com/docs/rental_insurance.pdf', 'landlord_insurance.pdf', 567000, 'application/pdf', '2024-03-01', '2025-03-01', 1800.00, 'Allstate', NULL, NULL, true),
|
||||||
|
(14, NOW() - INTERVAL '4 months', NOW(), 3, 2, NULL, 'Unit A Lease Agreement', 'Current tenant lease - Unit A', 'contract', 'https://example.com/docs/lease_a.pdf', 'unit_a_lease.pdf', 234000, 'application/pdf', '2024-04-01', '2025-03-31', NULL, NULL, NULL, NULL, true),
|
||||||
|
(15, NOW() - INTERVAL '3 months', NOW(), 3, 2, NULL, 'Unit B Lease Agreement', 'Current tenant lease - Unit B', 'contract', 'https://example.com/docs/lease_b.pdf', 'unit_b_lease.pdf', 234000, 'application/pdf', '2024-06-01', '2025-05-31', NULL, NULL, NULL, NULL, true),
|
||||||
|
(16, NOW() - INTERVAL '6 months', NOW(), 3, 2, 26, 'Fire Inspection Certificate', 'Annual fire safety compliance', 'general', 'https://example.com/docs/fire_cert.pdf', 'fire_inspection_2024.pdf', 123000, 'application/pdf', '2024-09-15', '2025-09-15', 150.00, 'City Fire Department', NULL, NULL, true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 4 - Jane's loft =====
|
||||||
|
(17, NOW() - INTERVAL '5 months', NOW(), 4, 3, NULL, 'Condo HOA Agreement', 'HOA rules and regulations', 'contract', 'https://example.com/docs/hoa_agreement.pdf', 'hoa_agreement.pdf', 890000, 'application/pdf', '2024-01-15', NULL, NULL, 'Downtown Towers HOA', NULL, NULL, true),
|
||||||
|
(18, NOW() - INTERVAL '4 months', NOW(), 4, 3, NULL, 'Nest Thermostat Warranty', '2-year Google warranty', 'warranty', 'https://example.com/docs/nest_warranty.pdf', 'nest_warranty.pdf', 145000, 'application/pdf', '2024-08-01', '2026-08-01', 275.00, 'Google Store', 'NEST-2024-99887', 'T3007ES', true),
|
||||||
|
(19, NOW() - INTERVAL '3 months', NOW(), 4, 3, NULL, 'Renters Insurance', 'Personal property coverage', 'insurance', 'https://example.com/docs/renters.pdf', 'renters_insurance.pdf', 234000, 'application/pdf', '2024-07-01', '2025-07-01', 240.00, 'Lemonade', NULL, NULL, true),
|
||||||
|
(20, NOW() - INTERVAL '2 months', NOW(), 4, 3, 29, 'Garbage Disposal Manual', 'InSinkErator installation guide', 'manual', 'https://example.com/docs/disposal_manual.pdf', 'disposal_manual.pdf', 2300000, 'application/pdf', NULL, NULL, NULL, 'InSinkErator', NULL, 'Evolution Excel', true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 5 - Bob's condo =====
|
||||||
|
(21, NOW() - INTERVAL '4 months', NOW(), 5, 4, NULL, 'Fireplace Warranty', 'Gas fireplace 5-year warranty', 'warranty', 'https://example.com/docs/fireplace.pdf', 'fireplace_warranty.pdf', 189000, 'application/pdf', '2022-11-10', '2027-11-10', 3500.00, 'Heat & Glo', 'HG-2022-45678', 'SL-750TRS-IPI', true),
|
||||||
|
(22, NOW() - INTERVAL '3 months', NOW(), 5, 4, NULL, 'Ski Condo HOA Docs', 'Mountain Village HOA documents', 'contract', 'https://example.com/docs/ski_hoa.pdf', 'mountain_village_hoa.pdf', 1200000, 'application/pdf', '2024-01-01', NULL, NULL, 'Mountain Village HOA', NULL, NULL, true),
|
||||||
|
(23, NOW() - INTERVAL '5 months', NOW(), 5, 4, 40, 'Deck Stain Receipt', 'Thompson''s WaterSeal purchase', 'receipt', 'https://example.com/docs/stain_receipt.pdf', 'deck_stain_receipt.pdf', 67000, 'application/pdf', '2024-08-20', NULL, 125.00, 'Home Depot', NULL, NULL, true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 8 - Alice's craftsman =====
|
||||||
|
(24, NOW() - INTERVAL '3 months', NOW(), 8, 5, NULL, 'Historic Home Certificate', 'National Register listing certificate', 'general', 'https://example.com/docs/historic_cert.pdf', 'historic_certificate.pdf', 456000, 'application/pdf', '1985-06-15', NULL, NULL, 'National Park Service', NULL, NULL, true),
|
||||||
|
(25, NOW() - INTERVAL '2 months', NOW(), 8, 5, 43, 'Window Restoration Invoice', 'Historic window restoration work', 'receipt', 'https://example.com/docs/window_invoice.pdf', 'window_restoration.pdf', 123000, 'application/pdf', '2024-10-01', NULL, 1650.00, 'Preservation Builders', NULL, NULL, true),
|
||||||
|
(26, NOW() - INTERVAL '4 months', NOW(), 8, 5, NULL, 'Vintage Stove Manual', '1920s O''Keefe & Merritt restoration guide', 'manual', 'https://example.com/docs/vintage_stove.pdf', 'okeefe_merritt_manual.pdf', 3400000, 'application/pdf', NULL, NULL, NULL, 'Antique Stove Hospital', NULL, 'Model 600', true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 11 - Edward's fixer upper =====
|
||||||
|
(27, NOW() - INTERVAL '2 weeks', NOW(), 11, 8, NULL, 'Renovation Permit', 'City building permit for renovation', 'general', 'https://example.com/docs/permit.pdf', 'building_permit.pdf', 234000, 'application/pdf', '2024-11-01', '2025-11-01', 850.00, 'City of Seattle', 'PERMIT-2024-78901', NULL, true),
|
||||||
|
(28, NOW() - INTERVAL '1 week', NOW(), 11, 8, 49, 'Cabinet Quote', 'Custom kitchen cabinet estimate', 'general', 'https://example.com/docs/cabinet_quote.pdf', 'cabinet_quote.pdf', 178000, 'application/pdf', '2024-11-20', NULL, 5000.00, 'Woodworks Plus', NULL, NULL, true),
|
||||||
|
(29, NOW() - INTERVAL '5 days', NOW(), 11, 8, NULL, 'Contractor Agreement', 'Renovation contract with Demo Dave', 'contract', 'https://example.com/docs/contractor.pdf', 'contractor_agreement.pdf', 345000, 'application/pdf', '2024-11-15', '2025-03-15', 15000.00, 'Demolition & Renovation', NULL, NULL, true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 12 - Fiona's studio =====
|
||||||
|
(30, NOW() - INTERVAL '1 week', NOW(), 12, 9, 53, 'Smart Lights Receipt', 'Philips Hue starter kit receipt', 'receipt', 'https://example.com/docs/hue_receipt.pdf', 'philips_hue_receipt.pdf', 89000, 'application/pdf', '2024-11-18', NULL, 420.00, 'Best Buy', NULL, 'Hue White and Color Ambiance', true),
|
||||||
|
(31, NOW() - INTERVAL '5 days', NOW(), 12, 9, NULL, 'Studio Lease', 'Apartment lease agreement', 'contract', 'https://example.com/docs/studio_lease.pdf', 'studio_lease.pdf', 456000, 'application/pdf', '2024-11-15', '2025-11-14', NULL, 'Arts District Properties', NULL, NULL, true),
|
||||||
|
|
||||||
|
-- ===== RESIDENCE 13 - George's townhome =====
|
||||||
|
(32, NOW() - INTERVAL '2 months', NOW(), 13, 12, NULL, 'Pool Heater Warranty', '3-year warranty on Jandy pool heater', 'warranty', 'https://example.com/docs/pool_heater.pdf', 'pool_heater_warranty.pdf', 198000, 'application/pdf', '2024-03-01', '2027-03-01', 1800.00, 'Pool Supply Arizona', 'JANDY-2024-34567', 'JXi400N', true),
|
||||||
|
(33, NOW() - INTERVAL '1 month', NOW(), 13, 12, NULL, 'HOA CC&Rs', 'Townhome community rules', 'contract', 'https://example.com/docs/townhome_ccr.pdf', 'ccr_documents.pdf', 2100000, 'application/pdf', NULL, NULL, NULL, 'Desert Palms HOA', NULL, NULL, true),
|
||||||
|
|
||||||
|
-- Expired warranty
|
||||||
|
(34, NOW() - INTERVAL '3 years', NOW(), 1, 2, NULL, 'Old Washer Warranty', 'Expired washing machine warranty', 'warranty', 'https://example.com/docs/old_washer.pdf', 'washer_warranty_expired.pdf', 145000, 'application/pdf', '2020-05-15', '2023-05-15', 800.00, 'Sears', 'WH-2020-11111', 'WTW5000DW', false)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- DOCUMENT IMAGES
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO task_documentimage (id, created_at, updated_at, document_id, image_url, caption)
|
||||||
|
VALUES
|
||||||
|
-- HVAC System (doc 1)
|
||||||
|
(1, NOW(), NOW(), 1, 'https://picsum.photos/seed/hvac1/800/600', 'HVAC unit exterior'),
|
||||||
|
(2, NOW(), NOW(), 1, 'https://picsum.photos/seed/hvac2/800/600', 'Serial number plate'),
|
||||||
|
(3, NOW(), NOW(), 1, 'https://picsum.photos/seed/hvac3/800/600', 'Installation complete'),
|
||||||
|
|
||||||
|
-- Water Heater (doc 2)
|
||||||
|
(4, NOW(), NOW(), 2, 'https://picsum.photos/seed/waterheater1/800/600', 'Water heater installed'),
|
||||||
|
(5, NOW(), NOW(), 2, 'https://picsum.photos/seed/waterheater2/800/600', 'Model and serial info'),
|
||||||
|
|
||||||
|
-- Refrigerator (doc 3)
|
||||||
|
(6, NOW(), NOW(), 3, 'https://picsum.photos/seed/fridge1/800/600', 'LG French Door refrigerator'),
|
||||||
|
(7, NOW(), NOW(), 3, 'https://picsum.photos/seed/fridge2/800/600', 'Interior view'),
|
||||||
|
|
||||||
|
-- Hurricane Shutters (doc 9)
|
||||||
|
(8, NOW(), NOW(), 9, 'https://picsum.photos/seed/shutter1/800/600', 'Accordion shutters closed'),
|
||||||
|
(9, NOW(), NOW(), 9, 'https://picsum.photos/seed/shutter2/800/600', 'Shutters open position'),
|
||||||
|
(10, NOW(), NOW(), 9, 'https://picsum.photos/seed/shutter3/800/600', 'Window protection detail'),
|
||||||
|
|
||||||
|
-- Pool Equipment (doc 10)
|
||||||
|
(11, NOW(), NOW(), 10, 'https://picsum.photos/seed/pool1/800/600', 'Pool pump installation'),
|
||||||
|
(12, NOW(), NOW(), 10, 'https://picsum.photos/seed/pool2/800/600', 'Filter system'),
|
||||||
|
|
||||||
|
-- Security Cameras (doc 11)
|
||||||
|
(13, NOW(), NOW(), 11, 'https://picsum.photos/seed/arlo1/800/600', 'Arlo camera box'),
|
||||||
|
(14, NOW(), NOW(), 11, 'https://picsum.photos/seed/arlo2/800/600', 'Camera mounted on wall'),
|
||||||
|
|
||||||
|
-- Historic Certificate (doc 24)
|
||||||
|
(15, NOW(), NOW(), 24, 'https://picsum.photos/seed/historic1/800/600', 'Historic plaque on house'),
|
||||||
|
(16, NOW(), NOW(), 24, 'https://picsum.photos/seed/historic2/800/600', 'Original craftsman details'),
|
||||||
|
|
||||||
|
-- Window Restoration (doc 25)
|
||||||
|
(17, NOW(), NOW(), 25, 'https://picsum.photos/seed/restore1/800/600', 'Before restoration'),
|
||||||
|
(18, NOW(), NOW(), 25, 'https://picsum.photos/seed/restore2/800/600', 'After restoration'),
|
||||||
|
(19, NOW(), NOW(), 25, 'https://picsum.photos/seed/restore3/800/600', 'Detail work complete'),
|
||||||
|
|
||||||
|
-- Smart Lights (doc 30)
|
||||||
|
(20, NOW(), NOW(), 30, 'https://picsum.photos/seed/huelight1/800/600', 'Hue starter kit'),
|
||||||
|
(21, NOW(), NOW(), 30, 'https://picsum.photos/seed/huelight2/800/600', 'Bulbs installed'),
|
||||||
|
|
||||||
|
-- Pool Heater (doc 32)
|
||||||
|
(22, NOW(), NOW(), 32, 'https://picsum.photos/seed/poolheat1/800/600', 'Jandy pool heater'),
|
||||||
|
(23, NOW(), NOW(), 32, 'https://picsum.photos/seed/poolheat2/800/600', 'Control panel'),
|
||||||
|
|
||||||
|
-- Renovation Permit (doc 27)
|
||||||
|
(24, NOW(), NOW(), 27, 'https://picsum.photos/seed/permit1/800/600', 'Permit posted on site')
|
||||||
|
ON CONFLICT (id) DO UPDATE SET image_url = EXCLUDED.image_url, caption = EXCLUDED.caption;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- NOTIFICATIONS (various types and states)
|
||||||
|
-- Types: task_due_soon, task_overdue, task_completed, task_assigned, residence_shared, warranty_expiring
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO notifications_notification (id, created_at, updated_at, user_id, notification_type, title, body, task_id, sent, sent_at, read, read_at)
|
||||||
|
VALUES
|
||||||
|
-- John's notifications (user 2)
|
||||||
|
(1, NOW() - INTERVAL '1 hour', NOW(), 2, 'task_due_soon', 'Task Due Soon', 'Fix leaky kitchen faucet is due in 7 days', 1, true, NOW() - INTERVAL '1 hour', false, NULL),
|
||||||
|
(2, NOW() - INTERVAL '3 hours', NOW(), 2, 'task_due_soon', 'Task Due Soon', 'Install ceiling fan is due in 14 days', 4, true, NOW() - INTERVAL '3 hours', true, NOW() - INTERVAL '2 hours'),
|
||||||
|
(3, NOW() - INTERVAL '1 day', NOW(), 2, 'task_completed', 'Task Completed', 'Mow lawn has been marked as completed by Jane', 9, true, NOW() - INTERVAL '1 day', true, NOW() - INTERVAL '20 hours'),
|
||||||
|
(4, NOW() - INTERVAL '2 days', NOW(), 2, 'warranty_expiring', 'Warranty Expiring Soon', 'Your Dishwasher Warranty expires in 60 days', NULL, true, NOW() - INTERVAL '2 days', false, NULL),
|
||||||
|
(5, NOW() - INTERVAL '3 days', NOW(), 2, 'task_overdue', 'Task Overdue', 'Check pool chemicals is overdue by 3 days', 16, true, NOW() - INTERVAL '3 days', true, NOW() - INTERVAL '3 days'),
|
||||||
|
|
||||||
|
-- Jane's notifications (user 3)
|
||||||
|
(6, NOW() - INTERVAL '2 hours', NOW(), 3, 'task_assigned', 'New Task Assigned', 'You have been assigned to: Fix garbage disposal', 29, true, NOW() - INTERVAL '2 hours', false, NULL),
|
||||||
|
(7, NOW() - INTERVAL '1 day', NOW(), 3, 'residence_shared', 'Residence Access Granted', 'John Doe has shared Main Family Home with you', NULL, true, NOW() - INTERVAL '1 day', true, NOW() - INTERVAL '22 hours'),
|
||||||
|
(8, NOW() - INTERVAL '4 days', NOW(), 3, 'task_completed', 'Task Completed', 'Unclog bathroom drain has been completed', 2, true, NOW() - INTERVAL '4 days', true, NOW() - INTERVAL '4 days'),
|
||||||
|
|
||||||
|
-- Bob's notifications (user 4)
|
||||||
|
(9, NOW() - INTERVAL '30 minutes', NOW(), 4, 'task_due_soon', 'Task Due Soon', 'Replace entry door weatherstrip is due in 10 days', 39, true, NOW() - INTERVAL '30 minutes', false, NULL),
|
||||||
|
(10, NOW() - INTERVAL '6 hours', NOW(), 4, 'task_completed', 'Task Completed', 'Winterize pipes completed successfully', 36, true, NOW() - INTERVAL '6 hours', true, NOW() - INTERVAL '5 hours'),
|
||||||
|
(11, NOW() - INTERVAL '2 days', NOW(), 4, 'task_due_soon', 'Task Due Soon', 'Tune ski equipment storage is due soon', 38, true, NOW() - INTERVAL '2 days', false, NULL),
|
||||||
|
|
||||||
|
-- Alice's notifications (user 5)
|
||||||
|
(12, NOW() - INTERVAL '4 hours', NOW(), 5, 'task_assigned', 'New Task Assigned', 'You have been assigned to: Refinish hardwood floors', 42, true, NOW() - INTERVAL '4 hours', true, NOW() - INTERVAL '3 hours'),
|
||||||
|
(13, NOW() - INTERVAL '1 day', NOW(), 5, 'warranty_expiring', 'Warranty Alert', 'Check your document warranties for upcoming expirations', NULL, true, NOW() - INTERVAL '1 day', false, NULL),
|
||||||
|
|
||||||
|
-- Edward's notifications (user 8)
|
||||||
|
(14, NOW() - INTERVAL '2 hours', NOW(), 8, 'task_due_soon', 'Task Due Soon', 'Demo bathroom tile is due in 7 days', 48, true, NOW() - INTERVAL '2 hours', false, NULL),
|
||||||
|
(15, NOW() - INTERVAL '5 hours', NOW(), 8, 'task_assigned', 'New Task Assigned', 'You have been assigned to: Run new electrical', 50, true, NOW() - INTERVAL '5 hours', true, NOW() - INTERVAL '4 hours'),
|
||||||
|
|
||||||
|
-- Fiona's notifications (user 9)
|
||||||
|
(16, NOW() - INTERVAL '1 hour', NOW(), 9, 'task_completed', 'Task Completed', 'Setup smart lights has been completed', 53, true, NOW() - INTERVAL '1 hour', true, NOW() - INTERVAL '45 minutes'),
|
||||||
|
(17, NOW() - INTERVAL '3 days', NOW(), 9, 'residence_shared', 'Shared Access', 'Alice has shared Craftsman Bungalow with you', NULL, true, NOW() - INTERVAL '3 days', true, NOW() - INTERVAL '3 days'),
|
||||||
|
|
||||||
|
-- George's notifications (user 12)
|
||||||
|
(18, NOW() - INTERVAL '5 hours', NOW(), 12, 'task_due_soon', 'Task Due Soon', 'Trim desert landscaping is due in 5 days', 59, true, NOW() - INTERVAL '5 hours', false, NULL),
|
||||||
|
(19, NOW() - INTERVAL '1 day', NOW(), 12, 'task_completed', 'Task Completed', 'Service pool equipment completed', 57, true, NOW() - INTERVAL '1 day', true, NOW() - INTERVAL '20 hours'),
|
||||||
|
|
||||||
|
-- Pending notifications (not yet sent)
|
||||||
|
(20, NOW() - INTERVAL '10 minutes', NOW(), 2, 'task_overdue', 'Task Overdue', 'Fertilize lawn is now overdue', 12, false, NULL, false, NULL),
|
||||||
|
(21, NOW() - INTERVAL '5 minutes', NOW(), 5, 'task_due_soon', 'Task Due Soon', 'Fix porch swing is due in 21 days', 46, false, NULL, false, NULL)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- NOTIFICATION PREFERENCES
|
||||||
|
-- =====================================================
|
||||||
INSERT INTO notifications_notificationpreference (id, created_at, updated_at, user_id, task_due_soon, task_overdue, task_completed, task_assigned, residence_shared, warranty_expiring)
|
INSERT INTO notifications_notificationpreference (id, created_at, updated_at, user_id, task_due_soon, task_overdue, task_completed, task_assigned, residence_shared, warranty_expiring)
|
||||||
VALUES
|
VALUES
|
||||||
(1, NOW(), NOW(), 1, true, true, true, true, true, true),
|
(1, NOW(), NOW(), 1, true, true, true, true, true, true),
|
||||||
(2, NOW(), NOW(), 2, true, true, true, true, true, true),
|
(2, NOW(), NOW(), 2, true, true, true, true, true, true),
|
||||||
(3, NOW(), NOW(), 3, true, true, false, true, true, false),
|
(3, NOW(), NOW(), 3, true, true, false, true, true, false),
|
||||||
(4, NOW(), NOW(), 4, false, false, false, false, false, false)
|
(4, NOW(), NOW(), 4, true, true, true, true, true, true),
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
(5, NOW(), NOW(), 5, true, true, true, true, true, true),
|
||||||
task_due_soon = EXCLUDED.task_due_soon,
|
(6, NOW(), NOW(), 6, false, false, false, false, false, false),
|
||||||
task_overdue = EXCLUDED.task_overdue,
|
(7, NOW(), NOW(), 7, true, false, false, true, true, false),
|
||||||
updated_at = NOW();
|
(8, NOW(), NOW(), 8, true, true, true, true, false, true),
|
||||||
|
(9, NOW(), NOW(), 9, true, true, true, true, true, true),
|
||||||
|
(10, NOW(), NOW(), 10, false, false, false, false, false, false),
|
||||||
|
(11, NOW(), NOW(), 11, true, true, true, true, true, true),
|
||||||
|
(12, NOW(), NOW(), 12, true, true, true, false, true, true)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET task_due_soon = EXCLUDED.task_due_soon, updated_at = NOW();
|
||||||
|
|
||||||
-- Reset sequences
|
-- =====================================================
|
||||||
|
-- PUSH DEVICES (APNs and GCM)
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO push_notifications_apnsdevice (id, user_id, name, device_id, registration_id, active, date_created)
|
||||||
|
VALUES
|
||||||
|
(1, 1, 'Admin iPhone 15 Pro', 'admin-device-001', 'apns-token-admin-001-xxxx', true, NOW() - INTERVAL '6 months'),
|
||||||
|
(2, 2, 'John iPhone 14', 'john-device-001', 'apns-token-john-001-xxxx', true, NOW() - INTERVAL '5 months'),
|
||||||
|
(3, 2, 'John iPad Pro', 'john-device-002', 'apns-token-john-002-xxxx', true, NOW() - INTERVAL '4 months'),
|
||||||
|
(4, 4, 'Bob iPhone 13', 'bob-device-001', 'apns-token-bob-001-xxxx', true, NOW() - INTERVAL '3 months'),
|
||||||
|
(5, 5, 'Alice iPhone 15', 'alice-device-001', 'apns-token-alice-001-xxxx', true, NOW() - INTERVAL '2 months'),
|
||||||
|
(6, 9, 'Fiona iPhone 14 Pro', 'fiona-device-001', 'apns-token-fiona-001-xxxx', true, NOW() - INTERVAL '1 month'),
|
||||||
|
(7, 10, 'Inactive iPhone', 'inactive-device-001', 'apns-token-inactive-xxxx', false, NOW() - INTERVAL '1 year')
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, active = EXCLUDED.active;
|
||||||
|
|
||||||
|
INSERT INTO push_notifications_gcmdevice (id, user_id, name, device_id, registration_id, active, cloud_message_type, date_created)
|
||||||
|
VALUES
|
||||||
|
(1, 3, 'Jane Pixel 8', 'jane-device-001', 'gcm-token-jane-001-xxxx', true, 'FCM', NOW() - INTERVAL '5 months'),
|
||||||
|
(2, 6, 'Charlie Galaxy S24', 'charlie-device-001', 'gcm-token-charlie-001-xxxx', true, 'FCM', NOW() - INTERVAL '4 months'),
|
||||||
|
(3, 8, 'Edward Pixel 7', 'edward-device-001', 'gcm-token-edward-001-xxxx', true, 'FCM', NOW() - INTERVAL '3 months'),
|
||||||
|
(4, 12, 'George Galaxy S23', 'george-device-001', 'gcm-token-george-001-xxxx', true, 'FCM', NOW() - INTERVAL '2 months'),
|
||||||
|
(5, 7, 'Diana old Android', 'diana-device-001', 'gcm-token-diana-001-xxxx', false, 'FCM', NOW() - INTERVAL '1 year')
|
||||||
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, active = EXCLUDED.active;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- ADMIN USERS
|
||||||
|
-- =====================================================
|
||||||
|
INSERT INTO admin_users (id, created_at, updated_at, email, password, first_name, last_name, role, is_active, last_login)
|
||||||
|
VALUES
|
||||||
|
(1, NOW() - INTERVAL '1 year', NOW(), 'superadmin@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Super', 'Admin', 'super_admin', true, NOW() - INTERVAL '1 hour'),
|
||||||
|
(2, NOW() - INTERVAL '6 months', NOW(), 'admin@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Regular', 'Admin', 'admin', true, NOW() - INTERVAL '2 hours'),
|
||||||
|
(3, NOW() - INTERVAL '3 months', NOW(), 'support@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Support', 'Staff', 'admin', true, NOW() - INTERVAL '1 day'),
|
||||||
|
(4, NOW() - INTERVAL '1 month', NOW(), 'inactive.admin@mycrib.com', '$2a$10$KB4rf2NNj0a80lwlwJhaFukE2/THJXbcGZMks7vR3zykyN4zkF6xi', 'Former', 'Admin', 'admin', false, NOW() - INTERVAL '2 months')
|
||||||
|
ON CONFLICT (email) DO UPDATE SET role = EXCLUDED.role, is_active = EXCLUDED.is_active, updated_at = NOW();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- RESET SEQUENCES
|
||||||
|
-- =====================================================
|
||||||
SELECT setval('auth_user_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM auth_user), false);
|
SELECT setval('auth_user_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM auth_user), false);
|
||||||
SELECT setval('user_userprofile_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM user_userprofile), false);
|
SELECT setval('user_userprofile_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM user_userprofile), false);
|
||||||
SELECT setval('subscription_usersubscription_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM subscription_usersubscription), false);
|
SELECT setval('subscription_usersubscription_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM subscription_usersubscription), false);
|
||||||
@@ -155,12 +607,36 @@ SELECT setval('residence_residence_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FRO
|
|||||||
SELECT setval('task_contractor_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_contractor), false);
|
SELECT setval('task_contractor_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_contractor), false);
|
||||||
SELECT setval('task_task_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_task), false);
|
SELECT setval('task_task_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_task), false);
|
||||||
SELECT setval('task_taskcompletion_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_taskcompletion), false);
|
SELECT setval('task_taskcompletion_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_taskcompletion), false);
|
||||||
|
SELECT setval('task_taskcompletionimage_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_taskcompletionimage), false);
|
||||||
SELECT setval('task_document_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_document), false);
|
SELECT setval('task_document_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_document), false);
|
||||||
|
SELECT setval('task_documentimage_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM task_documentimage), false);
|
||||||
SELECT setval('notifications_notification_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notification), false);
|
SELECT setval('notifications_notification_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notification), false);
|
||||||
SELECT setval('notifications_notificationpreference_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notificationpreference), false);
|
SELECT setval('notifications_notificationpreference_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notifications_notificationpreference), false);
|
||||||
|
SELECT setval('push_notifications_apnsdevice_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM push_notifications_apnsdevice), false);
|
||||||
|
SELECT setval('push_notifications_gcmdevice_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM push_notifications_gcmdevice), false);
|
||||||
|
SELECT setval('admin_users_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM admin_users), false);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- TEST DATA SUMMARY
|
||||||
|
-- =====================================================
|
||||||
|
-- Users: 12 (including admin, staff, inactive)
|
||||||
|
-- User Profiles: 12
|
||||||
|
-- Subscriptions: 12 (mix of free/pro, ios/android)
|
||||||
|
-- Residences: 14 (all property types)
|
||||||
|
-- Shared Residences: 5 sharing relationships
|
||||||
|
-- Contractors: 20 (all specialties, various ratings)
|
||||||
|
-- Tasks: 62 (all categories, priorities, statuses, frequencies)
|
||||||
|
-- Task Completions: 26 (with ratings 4-5)
|
||||||
|
-- Task Completion Images: 34
|
||||||
|
-- Documents: 34 (all types: warranty, insurance, contract, manual, receipt, general)
|
||||||
|
-- Document Images: 24
|
||||||
|
-- Notifications: 21 (all types, sent/pending, read/unread)
|
||||||
|
-- Notification Preferences: 12
|
||||||
|
-- APNs Devices: 7 (iOS devices)
|
||||||
|
-- GCM Devices: 5 (Android devices)
|
||||||
|
-- Admin Users: 4
|
||||||
|
|
||||||
|
-- Test Login Credentials:
|
||||||
|
-- Regular users: password123
|
||||||
|
-- Admin panel: superadmin@mycrib.com / password123
|
||||||
|
|
||||||
-- Test Users (password: password123):
|
|
||||||
-- - admin (admin@example.com) - Admin, Pro tier
|
|
||||||
-- - john (john@example.com) - Pro tier, owns 2 residences
|
|
||||||
-- - jane (jane@example.com) - Free tier, owns 1 residence, shared access to residence 1
|
|
||||||
-- - bob (bob@example.com) - Free tier
|
|
||||||
|
|||||||
Reference in New Issue
Block a user