From 15e361f9835bb66fe07589073bf8aec476348c8d Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 29 Nov 2025 01:18:25 -0600 Subject: [PATCH] Add admin panel pages for additional models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add confirmation codes management page - Add devices management page - Add feature benefits management page - Add password reset codes management page - Add promotions management page - Add share codes management page - Add corresponding backend handlers and routes - Update sidebar navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../(dashboard)/confirmation-codes/page.tsx | 324 ++++++++++++++ admin/src/app/(dashboard)/devices/page.tsx | 411 ++++++++++++++++++ .../app/(dashboard)/feature-benefits/page.tsx | 231 ++++++++++ .../(dashboard)/password-reset-codes/page.tsx | 346 +++++++++++++++ admin/src/app/(dashboard)/promotions/page.tsx | 282 ++++++++++++ .../src/app/(dashboard)/share-codes/page.tsx | 349 +++++++++++++++ admin/src/components/app-sidebar.tsx | 6 + admin/src/lib/api.ts | 334 ++++++++++++++ .../handlers/confirmation_code_handler.go | 164 +++++++ internal/admin/handlers/device_handler.go | 340 +++++++++++++++ .../admin/handlers/feature_benefit_handler.go | 245 +++++++++++ .../handlers/password_reset_code_handler.go | 170 ++++++++ internal/admin/handlers/promotion_handler.go | 322 ++++++++++++++ internal/admin/handlers/share_code_handler.go | 239 ++++++++++ internal/admin/routes.go | 68 +++ 15 files changed, 3831 insertions(+) create mode 100644 admin/src/app/(dashboard)/confirmation-codes/page.tsx create mode 100644 admin/src/app/(dashboard)/devices/page.tsx create mode 100644 admin/src/app/(dashboard)/feature-benefits/page.tsx create mode 100644 admin/src/app/(dashboard)/password-reset-codes/page.tsx create mode 100644 admin/src/app/(dashboard)/promotions/page.tsx create mode 100644 admin/src/app/(dashboard)/share-codes/page.tsx create mode 100644 internal/admin/handlers/confirmation_code_handler.go create mode 100644 internal/admin/handlers/device_handler.go create mode 100644 internal/admin/handlers/feature_benefit_handler.go create mode 100644 internal/admin/handlers/password_reset_code_handler.go create mode 100644 internal/admin/handlers/promotion_handler.go create mode 100644 internal/admin/handlers/share_code_handler.go diff --git a/admin/src/app/(dashboard)/confirmation-codes/page.tsx b/admin/src/app/(dashboard)/confirmation-codes/page.tsx new file mode 100644 index 0000000..412d843 --- /dev/null +++ b/admin/src/app/(dashboard)/confirmation-codes/page.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { Mail, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { confirmationCodesApi, type ConfirmationCode, type ConfirmationCodeListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; + +export default function ConfirmationCodesPage() { + const queryClient = useQueryClient(); + const [params, setParams] = useState({ + page: 1, + per_page: 20, + }); + const [search, setSearch] = useState(''); + const [selectedRows, setSelectedRows] = useState([]); + + const { data, isLoading, error } = useQuery({ + queryKey: ['confirmation-codes', params], + queryFn: () => confirmationCodesApi.list(params), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => confirmationCodesApi.delete(id), + onSuccess: () => { + toast.success('Confirmation code deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['confirmation-codes'] }); + }, + onError: () => { + toast.error('Failed to delete confirmation code'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => confirmationCodesApi.bulkDelete(ids), + onSuccess: () => { + toast.success('Confirmation codes deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['confirmation-codes'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete confirmation codes'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((c) => c.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const isExpired = (expiresAt: string) => { + return new Date(expiresAt) < new Date(); + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load confirmation codes
+
+ ); + } + + return ( +
+
+
+

Confirmation Codes

+

+ Manage email verification codes +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Codes? + + This will permanently delete {selectedRows.length} confirmation code(s). + + + + Cancel + bulkDeleteMutation.mutate(selectedRows)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + )} +
+ + {/* Table */} +
+ + + + + 0 && + selectedRows.length === data.data.length + } + onCheckedChange={handleSelectAll} + /> + + User + Email + Code + Status + Expires + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No confirmation codes found + + + ) : ( + data?.data?.map((code) => ( + + + + handleSelectRow(code.id, checked as boolean) + } + /> + + + + {code.username} + + + {code.email} + + + {code.code} + + + + {code.is_used ? ( + + + Used + + ) : isExpired(code.expires_at) ? ( + + + Expired + + ) : ( + + Active + + )} + + + {new Date(code.expires_at).toLocaleString()} + + + {new Date(code.created_at).toLocaleDateString()} + + + + + + + + + Delete Code? + + This will permanently delete the confirmation code for {code.username}. + + + + Cancel + deleteMutation.mutate(code.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + + )) + )} + +
+
+ + {/* Pagination */} + {data && totalPages > 1 && ( +
+
+ Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '} + {Math.min( + (params.page || 1) * (params.per_page || 20), + data.total + )}{' '} + of {data.total} codes +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/app/(dashboard)/devices/page.tsx b/admin/src/app/(dashboard)/devices/page.tsx new file mode 100644 index 0000000..710dd25 --- /dev/null +++ b/admin/src/app/(dashboard)/devices/page.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { Smartphone, Trash2, Search, ChevronLeft, ChevronRight, Apple, Tablet } from 'lucide-react'; +import { toast } from 'sonner'; + +import { devicesApi, type APNSDevice, type GCMDevice, type DeviceListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +export default function DevicesPage() { + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState<'apns' | 'gcm'>('apns'); + const [params, setParams] = useState({ + page: 1, + per_page: 20, + }); + const [search, setSearch] = useState(''); + const [selectedRows, setSelectedRows] = useState([]); + + const { data: stats } = useQuery({ + queryKey: ['device-stats'], + queryFn: () => devicesApi.getStats(), + }); + + const { data: apnsData, isLoading: apnsLoading } = useQuery({ + queryKey: ['devices-apns', params], + queryFn: () => devicesApi.listAPNS(params), + enabled: activeTab === 'apns', + }); + + const { data: gcmData, isLoading: gcmLoading } = useQuery({ + queryKey: ['devices-gcm', params], + queryFn: () => devicesApi.listGCM(params), + enabled: activeTab === 'gcm', + }); + + const updateAPNSMutation = useMutation({ + mutationFn: ({ id, active }: { id: number; active: boolean }) => + devicesApi.updateAPNS(id, { active }), + onSuccess: () => { + toast.success('Device updated'); + queryClient.invalidateQueries({ queryKey: ['devices-apns'] }); + queryClient.invalidateQueries({ queryKey: ['device-stats'] }); + }, + }); + + const updateGCMMutation = useMutation({ + mutationFn: ({ id, active }: { id: number; active: boolean }) => + devicesApi.updateGCM(id, { active }), + onSuccess: () => { + toast.success('Device updated'); + queryClient.invalidateQueries({ queryKey: ['devices-gcm'] }); + queryClient.invalidateQueries({ queryKey: ['device-stats'] }); + }, + }); + + const deleteAPNSMutation = useMutation({ + mutationFn: (id: number) => devicesApi.deleteAPNS(id), + onSuccess: () => { + toast.success('Device deleted'); + queryClient.invalidateQueries({ queryKey: ['devices-apns'] }); + queryClient.invalidateQueries({ queryKey: ['device-stats'] }); + }, + }); + + const deleteGCMMutation = useMutation({ + mutationFn: (id: number) => devicesApi.deleteGCM(id), + onSuccess: () => { + toast.success('Device deleted'); + queryClient.invalidateQueries({ queryKey: ['devices-gcm'] }); + queryClient.invalidateQueries({ queryKey: ['device-stats'] }); + }, + }); + + const bulkDeleteAPNSMutation = useMutation({ + mutationFn: (ids: number[]) => devicesApi.bulkDeleteAPNS(ids), + onSuccess: () => { + toast.success('Devices deleted'); + queryClient.invalidateQueries({ queryKey: ['devices-apns'] }); + queryClient.invalidateQueries({ queryKey: ['device-stats'] }); + setSelectedRows([]); + }, + }); + + const bulkDeleteGCMMutation = useMutation({ + mutationFn: (ids: number[]) => devicesApi.bulkDeleteGCM(ids), + onSuccess: () => { + toast.success('Devices deleted'); + queryClient.invalidateQueries({ queryKey: ['devices-gcm'] }); + queryClient.invalidateQueries({ queryKey: ['device-stats'] }); + setSelectedRows([]); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const data = activeTab === 'apns' ? apnsData : gcmData; + const isLoading = activeTab === 'apns' ? apnsLoading : gcmLoading; + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + return ( +
+
+

Push Notification Devices

+

+ Manage registered iOS and Android devices +

+
+ + {/* Stats Cards */} +
+ + + + + iOS Devices + + + +
{stats?.apns.total || 0}
+

+ {stats?.apns.active || 0} active +

+
+
+ + + + + Android Devices + + + +
{stats?.gcm.total || 0}
+

+ {stats?.gcm.active || 0} active +

+
+
+ + + + + Total Devices + + + +
{stats?.total || 0}
+
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Devices? + + This will permanently delete {selectedRows.length} device(s). + Users will need to re-register for push notifications. + + + + Cancel + activeTab === 'apns' + ? bulkDeleteAPNSMutation.mutate(selectedRows) + : bulkDeleteGCMMutation.mutate(selectedRows) + } + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + )} +
+ + {/* Table */} +
+ + + + + 0 && selectedRows.length === data.data.length} + onCheckedChange={(checked) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((d) => d.id)); + } else { + setSelectedRows([]); + } + }} + /> + + Name + User + Device ID + Active + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No devices found + + + ) : ( + data?.data?.map((device) => ( + + + { + if (checked) { + setSelectedRows([...selectedRows, device.id]); + } else { + setSelectedRows(selectedRows.filter((id) => id !== device.id)); + } + }} + /> + + {device.name || 'Unknown'} + + {device.user_id ? ( + + {device.username || `User #${device.user_id}`} + + ) : ( + Anonymous + )} + + + + {device.device_id.substring(0, 12)}... + + + + + activeTab === 'apns' + ? updateAPNSMutation.mutate({ id: device.id, active: checked }) + : updateGCMMutation.mutate({ id: device.id, active: checked }) + } + /> + + + {new Date(device.date_created).toLocaleDateString()} + + + + + + + + + Delete Device? + + This will permanently delete this device registration. + + + + Cancel + + activeTab === 'apns' + ? deleteAPNSMutation.mutate(device.id) + : deleteGCMMutation.mutate(device.id) + } + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + + )) + )} + +
+
+ + {/* Pagination */} + {data && totalPages > 1 && ( +
+
+ Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '} + {Math.min((params.page || 1) * (params.per_page || 20), data.total)} of {data.total} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/app/(dashboard)/feature-benefits/page.tsx b/admin/src/app/(dashboard)/feature-benefits/page.tsx new file mode 100644 index 0000000..278e5a7 --- /dev/null +++ b/admin/src/app/(dashboard)/feature-benefits/page.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Star, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; + +import { featureBenefitsApi, type FeatureBenefit } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; + +export default function FeatureBenefitsPage() { + const queryClient = useQueryClient(); + const [params, setParams] = useState({ page: 1, per_page: 20, search: '' }); + const [search, setSearch] = useState(''); + const [editingBenefit, setEditingBenefit] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [formData, setFormData] = useState({ + feature_name: '', + free_tier_text: '', + pro_tier_text: '', + display_order: 0, + is_active: true, + }); + + const { data, isLoading } = useQuery({ + queryKey: ['feature-benefits', params], + queryFn: () => featureBenefitsApi.list(params), + }); + + const createMutation = useMutation({ + mutationFn: featureBenefitsApi.create, + onSuccess: () => { + toast.success('Feature benefit created'); + queryClient.invalidateQueries({ queryKey: ['feature-benefits'] }); + setIsCreating(false); + resetForm(); + }, + onError: () => toast.error('Failed to create feature benefit'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: any }) => featureBenefitsApi.update(id, data), + onSuccess: () => { + toast.success('Feature benefit updated'); + queryClient.invalidateQueries({ queryKey: ['feature-benefits'] }); + setEditingBenefit(null); + resetForm(); + }, + onError: () => toast.error('Failed to update feature benefit'), + }); + + const deleteMutation = useMutation({ + mutationFn: featureBenefitsApi.delete, + onSuccess: () => { + toast.success('Feature benefit deleted'); + queryClient.invalidateQueries({ queryKey: ['feature-benefits'] }); + }, + onError: () => toast.error('Failed to delete feature benefit'), + }); + + const resetForm = () => { + setFormData({ feature_name: '', free_tier_text: '', pro_tier_text: '', display_order: 0, is_active: true }); + }; + + const handleEdit = (benefit: FeatureBenefit) => { + setEditingBenefit(benefit); + setFormData({ + feature_name: benefit.feature_name, + free_tier_text: benefit.free_tier_text, + pro_tier_text: benefit.pro_tier_text, + display_order: benefit.display_order, + is_active: benefit.is_active, + }); + }; + + const handleSubmit = () => { + if (editingBenefit) { + updateMutation.mutate({ id: editingBenefit.id, data: formData }); + } else { + createMutation.mutate(formData); + } + }; + + const totalPages = data ? Math.ceil(data.total / params.per_page) : 0; + + return ( +
+
+
+

Feature Benefits

+

Manage subscription tier comparison features

+
+ +
+ +
{ e.preventDefault(); setParams({ ...params, search, page: 1 }); }} className="flex gap-2 max-w-md"> +
+ + setSearch(e.target.value)} className="pl-9" /> +
+ +
+ +
+ + + + Order + Feature + Free Tier + Pro Tier + Active + Actions + + + + {isLoading ? ( + Loading... + ) : data?.data?.length === 0 ? ( + No feature benefits found + ) : ( + data?.data?.map((benefit) => ( + + {benefit.display_order} + {benefit.feature_name} + {benefit.free_tier_text} + {benefit.pro_tier_text} + + + {benefit.is_active ? 'Active' : 'Inactive'} + + + +
+ + + + + + + + Delete Feature? + This will permanently delete this feature benefit. + + + Cancel + deleteMutation.mutate(benefit.id)} className="bg-destructive">Delete + + + +
+
+
+ )) + )} +
+
+
+ + {data && totalPages > 1 && ( +
+
Page {params.page} of {totalPages}
+
+ + +
+
+ )} + + {/* Create/Edit Dialog */} + { if (!open) { setIsCreating(false); setEditingBenefit(null); } }}> + + + {editingBenefit ? 'Edit Feature Benefit' : 'Create Feature Benefit'} + +
+
+ + setFormData({ ...formData, feature_name: e.target.value })} /> +
+
+ + setFormData({ ...formData, free_tier_text: e.target.value })} /> +
+
+ + setFormData({ ...formData, pro_tier_text: e.target.value })} /> +
+
+ + setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })} /> +
+
+ setFormData({ ...formData, is_active: checked })} /> + +
+
+ + + + +
+
+
+ ); +} diff --git a/admin/src/app/(dashboard)/password-reset-codes/page.tsx b/admin/src/app/(dashboard)/password-reset-codes/page.tsx new file mode 100644 index 0000000..d83c9ca --- /dev/null +++ b/admin/src/app/(dashboard)/password-reset-codes/page.tsx @@ -0,0 +1,346 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { KeyRound, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { passwordResetCodesApi, type PasswordResetCode, type PasswordResetCodeListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; + +export default function PasswordResetCodesPage() { + const queryClient = useQueryClient(); + const [params, setParams] = useState({ + page: 1, + per_page: 20, + }); + const [search, setSearch] = useState(''); + const [selectedRows, setSelectedRows] = useState([]); + + const { data, isLoading, error } = useQuery({ + queryKey: ['password-reset-codes', params], + queryFn: () => passwordResetCodesApi.list(params), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => passwordResetCodesApi.delete(id), + onSuccess: () => { + toast.success('Password reset code deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['password-reset-codes'] }); + }, + onError: () => { + toast.error('Failed to delete password reset code'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => passwordResetCodesApi.bulkDelete(ids), + onSuccess: () => { + toast.success('Password reset codes deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['password-reset-codes'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete password reset codes'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((c) => c.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const isExpired = (expiresAt: string) => { + return new Date(expiresAt) < new Date(); + }; + + const getStatusBadge = (code: PasswordResetCode) => { + if (code.used) { + return ( + + + Used + + ); + } + if (code.attempts >= code.max_attempts) { + return ( + + + Max Attempts + + ); + } + if (isExpired(code.expires_at)) { + return ( + + + Expired + + ); + } + return ( + + Active + + ); + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load password reset codes
+
+ ); + } + + return ( +
+
+
+

Password Reset Codes

+

+ Manage password reset requests +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Codes? + + This will permanently delete {selectedRows.length} password reset code(s). + + + + Cancel + bulkDeleteMutation.mutate(selectedRows)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + )} +
+ + {/* Table */} +
+ + + + + 0 && + selectedRows.length === data.data.length + } + onCheckedChange={handleSelectAll} + /> + + User + Email + Token + Status + Attempts + Expires + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No password reset codes found + + + ) : ( + data?.data?.map((code) => ( + + + + handleSelectRow(code.id, checked as boolean) + } + /> + + + + {code.username} + + + {code.email} + + + {code.reset_token} + + + {getStatusBadge(code)} + + = code.max_attempts ? 'text-red-600 font-medium' : ''}> + {code.attempts} / {code.max_attempts} + + + + {new Date(code.expires_at).toLocaleString()} + + + {new Date(code.created_at).toLocaleDateString()} + + + + + + + + + Delete Code? + + This will permanently delete the password reset code for {code.username}. + + + + Cancel + deleteMutation.mutate(code.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + + )) + )} + +
+
+ + {/* Pagination */} + {data && totalPages > 1 && ( +
+
+ Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '} + {Math.min( + (params.page || 1) * (params.per_page || 20), + data.total + )}{' '} + of {data.total} codes +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/app/(dashboard)/promotions/page.tsx b/admin/src/app/(dashboard)/promotions/page.tsx new file mode 100644 index 0000000..064b566 --- /dev/null +++ b/admin/src/app/(dashboard)/promotions/page.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Megaphone, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; + +import { promotionsApi, type Promotion } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; + +export default function PromotionsPage() { + const queryClient = useQueryClient(); + const [params, setParams] = useState({ page: 1, per_page: 20, search: '' }); + const [search, setSearch] = useState(''); + const [editingPromotion, setEditingPromotion] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [formData, setFormData] = useState({ + promotion_id: '', + title: '', + message: '', + link: '', + start_date: '', + end_date: '', + target_tier: 'free' as 'free' | 'pro', + is_active: true, + }); + + const { data, isLoading } = useQuery({ + queryKey: ['promotions', params], + queryFn: () => promotionsApi.list(params), + }); + + const createMutation = useMutation({ + mutationFn: promotionsApi.create, + onSuccess: () => { + toast.success('Promotion created'); + queryClient.invalidateQueries({ queryKey: ['promotions'] }); + setIsCreating(false); + resetForm(); + }, + onError: () => toast.error('Failed to create promotion'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: any }) => promotionsApi.update(id, data), + onSuccess: () => { + toast.success('Promotion updated'); + queryClient.invalidateQueries({ queryKey: ['promotions'] }); + setEditingPromotion(null); + resetForm(); + }, + onError: () => toast.error('Failed to update promotion'), + }); + + const deleteMutation = useMutation({ + mutationFn: promotionsApi.delete, + onSuccess: () => { + toast.success('Promotion deleted'); + queryClient.invalidateQueries({ queryKey: ['promotions'] }); + }, + onError: () => toast.error('Failed to delete promotion'), + }); + + const resetForm = () => { + setFormData({ + promotion_id: '', title: '', message: '', link: '', + start_date: '', end_date: '', target_tier: 'free', is_active: true, + }); + }; + + const handleEdit = (promo: Promotion) => { + setEditingPromotion(promo); + setFormData({ + promotion_id: promo.promotion_id, + title: promo.title, + message: promo.message, + link: promo.link || '', + start_date: promo.start_date.split('T')[0], + end_date: promo.end_date.split('T')[0], + target_tier: promo.target_tier, + is_active: promo.is_active, + }); + }; + + const handleSubmit = () => { + const submitData = { + ...formData, + link: formData.link || undefined, + }; + if (editingPromotion) { + updateMutation.mutate({ id: editingPromotion.id, data: submitData }); + } else { + createMutation.mutate(submitData); + } + }; + + const isActive = (promo: Promotion) => { + const now = new Date(); + return promo.is_active && new Date(promo.start_date) <= now && new Date(promo.end_date) >= now; + }; + + const totalPages = data ? Math.ceil(data.total / params.per_page) : 0; + + return ( +
+
+
+

Promotions

+

Manage promotional content and banners

+
+ +
+ +
{ e.preventDefault(); setParams({ ...params, search, page: 1 }); }} className="flex gap-2 max-w-md"> +
+ + setSearch(e.target.value)} className="pl-9" /> +
+ +
+ +
+ + + + ID + Title + Target + Date Range + Status + Actions + + + + {isLoading ? ( + Loading... + ) : data?.data?.length === 0 ? ( + No promotions found + ) : ( + data?.data?.map((promo) => ( + + {promo.promotion_id} + {promo.title} + + + {promo.target_tier === 'pro' ? 'Pro Users' : 'Free Users'} + + + + {new Date(promo.start_date).toLocaleDateString()} - {new Date(promo.end_date).toLocaleDateString()} + + + {isActive(promo) ? ( + Active + ) : promo.is_active ? ( + Scheduled + ) : ( + Inactive + )} + + +
+ + + + + + + + Delete Promotion? + This will permanently delete this promotion. + + + Cancel + deleteMutation.mutate(promo.id)} className="bg-destructive">Delete + + + +
+
+
+ )) + )} +
+
+
+ + {data && totalPages > 1 && ( +
+
Page {params.page} of {totalPages}
+
+ + +
+
+ )} + + {/* Create/Edit Dialog */} + { if (!open) { setIsCreating(false); setEditingPromotion(null); } }}> + + + {editingPromotion ? 'Edit Promotion' : 'Create Promotion'} + +
+
+ + setFormData({ ...formData, promotion_id: e.target.value })} placeholder="e.g., summer_sale_2024" /> +
+
+ + setFormData({ ...formData, title: e.target.value })} /> +
+
+ + setFormData({ ...formData, message: e.target.value })} /> +
+
+ + setFormData({ ...formData, link: e.target.value })} placeholder="https://..." /> +
+
+
+ + setFormData({ ...formData, start_date: e.target.value })} /> +
+
+ + setFormData({ ...formData, end_date: e.target.value })} /> +
+
+
+ + +
+
+ setFormData({ ...formData, is_active: checked })} /> + +
+
+ + + + +
+
+
+ ); +} diff --git a/admin/src/app/(dashboard)/share-codes/page.tsx b/admin/src/app/(dashboard)/share-codes/page.tsx new file mode 100644 index 0000000..c51686e --- /dev/null +++ b/admin/src/app/(dashboard)/share-codes/page.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { Share2, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { shareCodesApi, type ShareCode, type ShareCodeListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; + +export default function ShareCodesPage() { + const queryClient = useQueryClient(); + const [params, setParams] = useState({ + page: 1, + per_page: 20, + }); + const [search, setSearch] = useState(''); + const [selectedRows, setSelectedRows] = useState([]); + + const { data, isLoading, error } = useQuery({ + queryKey: ['share-codes', params], + queryFn: () => shareCodesApi.list(params), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) => + shareCodesApi.update(id, { is_active }), + onSuccess: () => { + toast.success('Share code updated successfully'); + queryClient.invalidateQueries({ queryKey: ['share-codes'] }); + }, + onError: () => { + toast.error('Failed to update share code'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => shareCodesApi.delete(id), + onSuccess: () => { + toast.success('Share code deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['share-codes'] }); + }, + onError: () => { + toast.error('Failed to delete share code'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => shareCodesApi.bulkDelete(ids), + onSuccess: () => { + toast.success('Share codes deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['share-codes'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete share codes'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((c) => c.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const isExpired = (expiresAt: string | null) => { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load share codes
+
+ ); + } + + return ( +
+
+
+

Share Codes

+

+ Manage residence sharing codes +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Codes? + + This will permanently delete {selectedRows.length} share code(s). + Users who have already joined will remain on the residence. + + + + Cancel + bulkDeleteMutation.mutate(selectedRows)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + )} +
+ + {/* Table */} +
+ + + + + 0 && + selectedRows.length === data.data.length + } + onCheckedChange={handleSelectAll} + /> + + Code + Residence + Created By + Active + Expires + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No share codes found + + + ) : ( + data?.data?.map((code) => ( + + + + handleSelectRow(code.id, checked as boolean) + } + /> + + + + {code.code} + + + + + {code.residence_name} + + + + + {code.created_by} + + + + + updateMutation.mutate({ id: code.id, is_active: checked }) + } + /> + + + {code.expires_at ? ( + isExpired(code.expires_at) ? ( + + + Expired + + ) : ( + new Date(code.expires_at).toLocaleDateString() + ) + ) : ( + Never + )} + + + {new Date(code.created_at).toLocaleDateString()} + + + + + + + + + Delete Code? + + This will permanently delete the share code for {code.residence_name}. + Users who have already joined will remain on the residence. + + + + Cancel + deleteMutation.mutate(code.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + + )) + )} + +
+
+ + {/* Pagination */} + {data && totalPages > 1 && ( +
+
+ Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '} + {Math.min( + (params.page || 1) * (params.per_page || 20), + data.total + )}{' '} + of {data.total} codes +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/components/app-sidebar.tsx b/admin/src/components/app-sidebar.tsx index 5762312..be825ca 100644 --- a/admin/src/components/app-sidebar.tsx +++ b/admin/src/components/app-sidebar.tsx @@ -19,6 +19,9 @@ import { Shield, Layers, Sparkles, + Mail, + Share2, + KeyRound, } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; import { useAuthStore } from '@/store/auth'; @@ -40,7 +43,10 @@ const menuItems = [ { title: 'Dashboard', url: '/admin/', icon: Home }, { title: 'Users', url: '/admin/users', icon: Users }, { title: 'Auth Tokens', url: '/admin/auth-tokens', icon: Key }, + { title: 'Confirmation Codes', url: '/admin/confirmation-codes', icon: Mail }, + { title: 'Password Resets', url: '/admin/password-reset-codes', icon: KeyRound }, { title: 'Residences', url: '/admin/residences', icon: Building2 }, + { title: 'Share Codes', url: '/admin/share-codes', icon: Share2 }, { title: 'Tasks', url: '/admin/tasks', icon: ClipboardList }, { title: 'Completions', url: '/admin/completions', icon: CheckCircle }, { title: 'Contractors', url: '/admin/contractors', icon: Wrench }, diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index fd09978..c2e6c6b 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -795,4 +795,338 @@ export const limitationsApi = { }, }; +// Confirmation Codes Types +export interface ConfirmationCode { + id: number; + user_id: number; + username: string; + email: string; + code: string; + expires_at: string; + is_used: boolean; + created_at: string; +} + +export interface ConfirmationCodeListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; +} + +// Confirmation Codes API +export const confirmationCodesApi = { + list: async (params?: ConfirmationCodeListParams): Promise> => { + const response = await api.get>('/confirmation-codes', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/confirmation-codes/${id}`); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/confirmation-codes/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/confirmation-codes/bulk', { data: { ids } }); + }, +}; + +// Share Codes Types +export interface ShareCode { + id: number; + residence_id: number; + residence_name: string; + code: string; + created_by_id: number; + created_by: string; + is_active: boolean; + expires_at: string | null; + created_at: string; +} + +export interface ShareCodeListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; +} + +export interface UpdateShareCodeRequest { + is_active: boolean; +} + +// Share Codes API +export const shareCodesApi = { + list: async (params?: ShareCodeListParams): Promise> => { + const response = await api.get>('/share-codes', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/share-codes/${id}`); + return response.data; + }, + + update: async (id: number, data: UpdateShareCodeRequest): Promise => { + const response = await api.put(`/share-codes/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/share-codes/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/share-codes/bulk', { data: { ids } }); + }, +}; + +// Password Reset Codes Types +export interface PasswordResetCode { + id: number; + user_id: number; + username: string; + email: string; + reset_token: string; + expires_at: string; + used: boolean; + attempts: number; + max_attempts: number; + created_at: string; +} + +export interface PasswordResetCodeListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; +} + +// Password Reset Codes API +export const passwordResetCodesApi = { + list: async (params?: PasswordResetCodeListParams): Promise> => { + const response = await api.get>('/password-reset-codes', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/password-reset-codes/${id}`); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/password-reset-codes/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/password-reset-codes/bulk', { data: { ids } }); + }, +}; + +// Device Types +export interface APNSDevice { + id: number; + name: string; + active: boolean; + user_id: number | null; + username: string | null; + device_id: string; + registration_id: string; + date_created: string; +} + +export interface GCMDevice { + id: number; + name: string; + active: boolean; + user_id: number | null; + username: string | null; + device_id: string; + registration_id: string; + cloud_message_type: string; + date_created: string; +} + +export interface DeviceListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; +} + +export interface DeviceStats { + apns: { total: number; active: number }; + gcm: { total: number; active: number }; + total: number; +} + +// Devices API +export const devicesApi = { + getStats: async (): Promise => { + const response = await api.get('/devices/stats'); + return response.data; + }, + + listAPNS: async (params?: DeviceListParams): Promise> => { + const response = await api.get>('/devices/apns', { params }); + return response.data; + }, + + listGCM: async (params?: DeviceListParams): Promise> => { + const response = await api.get>('/devices/gcm', { params }); + return response.data; + }, + + updateAPNS: async (id: number, data: { active: boolean }): Promise => { + await api.put(`/devices/apns/${id}`, data); + }, + + updateGCM: async (id: number, data: { active: boolean }): Promise => { + await api.put(`/devices/gcm/${id}`, data); + }, + + deleteAPNS: async (id: number): Promise => { + await api.delete(`/devices/apns/${id}`); + }, + + deleteGCM: async (id: number): Promise => { + await api.delete(`/devices/gcm/${id}`); + }, + + bulkDeleteAPNS: async (ids: number[]): Promise => { + await api.delete('/devices/apns/bulk', { data: { ids } }); + }, + + bulkDeleteGCM: async (ids: number[]): Promise => { + await api.delete('/devices/gcm/bulk', { data: { ids } }); + }, +}; + +// Feature Benefit Types +export interface FeatureBenefit { + id: number; + feature_name: string; + free_tier_text: string; + pro_tier_text: string; + display_order: number; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateFeatureBenefitRequest { + feature_name: string; + free_tier_text: string; + pro_tier_text: string; + display_order?: number; + is_active?: boolean; +} + +export interface UpdateFeatureBenefitRequest { + feature_name?: string; + free_tier_text?: string; + pro_tier_text?: string; + display_order?: number; + is_active?: boolean; +} + +// Feature Benefits API +export const featureBenefitsApi = { + list: async (params?: { page?: number; per_page?: number; search?: string }): Promise> => { + const response = await api.get>('/feature-benefits', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/feature-benefits/${id}`); + return response.data; + }, + + create: async (data: CreateFeatureBenefitRequest): Promise => { + const response = await api.post('/feature-benefits', data); + return response.data; + }, + + update: async (id: number, data: UpdateFeatureBenefitRequest): Promise => { + const response = await api.put(`/feature-benefits/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/feature-benefits/${id}`); + }, +}; + +// Promotion Types +export interface Promotion { + id: number; + promotion_id: string; + title: string; + message: string; + link: string | null; + start_date: string; + end_date: string; + target_tier: 'free' | 'pro'; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreatePromotionRequest { + promotion_id: string; + title: string; + message: string; + link?: string; + start_date: string; + end_date: string; + target_tier?: 'free' | 'pro'; + is_active?: boolean; +} + +export interface UpdatePromotionRequest { + promotion_id?: string; + title?: string; + message?: string; + link?: string; + start_date?: string; + end_date?: string; + target_tier?: 'free' | 'pro'; + is_active?: boolean; +} + +// Promotions API +export const promotionsApi = { + list: async (params?: { page?: number; per_page?: number; search?: string }): Promise> => { + const response = await api.get>('/promotions', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/promotions/${id}`); + return response.data; + }, + + create: async (data: CreatePromotionRequest): Promise => { + const response = await api.post('/promotions', data); + return response.data; + }, + + update: async (id: number, data: UpdatePromotionRequest): Promise => { + const response = await api.put(`/promotions/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/promotions/${id}`); + }, +}; + export default api; diff --git a/internal/admin/handlers/confirmation_code_handler.go b/internal/admin/handlers/confirmation_code_handler.go new file mode 100644 index 0000000..e7d4114 --- /dev/null +++ b/internal/admin/handlers/confirmation_code_handler.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/treytartt/casera-api/internal/admin/dto" + "github.com/treytartt/casera-api/internal/models" +) + +// AdminConfirmationCodeHandler handles admin confirmation code management endpoints +type AdminConfirmationCodeHandler struct { + db *gorm.DB +} + +// NewAdminConfirmationCodeHandler creates a new admin confirmation code handler +func NewAdminConfirmationCodeHandler(db *gorm.DB) *AdminConfirmationCodeHandler { + return &AdminConfirmationCodeHandler{db: db} +} + +// ConfirmationCodeResponse represents a confirmation code in API responses +type ConfirmationCodeResponse struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + Code string `json:"code"` + ExpiresAt string `json:"expires_at"` + IsUsed bool `json:"is_used"` + CreatedAt string `json:"created_at"` +} + +// List handles GET /api/admin/confirmation-codes +func (h *AdminConfirmationCodeHandler) List(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var codes []models.ConfirmationCode + var total int64 + + query := h.db.Model(&models.ConfirmationCode{}).Preload("User") + + // Apply search (search by user info or code) + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("JOIN auth_user ON auth_user.id = user_confirmationcode.user_id"). + Where( + "auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_confirmationcode.code ILIKE ?", + search, search, search, + ) + } + + // Get total count + query.Count(&total) + + // Apply sorting + sortBy := "created_at" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + + // Apply pagination + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&codes).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch confirmation codes"}) + return + } + + // Build response + responses := make([]ConfirmationCodeResponse, len(codes)) + for i, code := range codes { + responses[i] = ConfirmationCodeResponse{ + ID: code.ID, + UserID: code.UserID, + Username: code.User.Username, + Email: code.User.Email, + Code: code.Code, + ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"), + IsUsed: code.IsUsed, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/confirmation-codes/:id +func (h *AdminConfirmationCodeHandler) Get(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 code models.ConfirmationCode + if err := h.db.Preload("User").First(&code, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Confirmation code not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch confirmation code"}) + return + } + + response := ConfirmationCodeResponse{ + ID: code.ID, + UserID: code.UserID, + Username: code.User.Username, + Email: code.User.Email, + Code: code.Code, + ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"), + IsUsed: code.IsUsed, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + + c.JSON(http.StatusOK, response) +} + +// Delete handles DELETE /api/admin/confirmation-codes/:id +func (h *AdminConfirmationCodeHandler) Delete(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 + } + + result := h.db.Delete(&models.ConfirmationCode{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation code"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Confirmation code not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Confirmation code deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/confirmation-codes/bulk +func (h *AdminConfirmationCodeHandler) BulkDelete(c *gin.Context) { + var req dto.BulkDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result := h.db.Where("id IN ?", req.IDs).Delete(&models.ConfirmationCode{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation codes"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Confirmation codes deleted successfully", "count": result.RowsAffected}) +} diff --git a/internal/admin/handlers/device_handler.go b/internal/admin/handlers/device_handler.go new file mode 100644 index 0000000..4700465 --- /dev/null +++ b/internal/admin/handlers/device_handler.go @@ -0,0 +1,340 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/treytartt/casera-api/internal/admin/dto" + "github.com/treytartt/casera-api/internal/models" +) + +// AdminDeviceHandler handles admin device management endpoints +type AdminDeviceHandler struct { + db *gorm.DB +} + +// NewAdminDeviceHandler creates a new admin device handler +func NewAdminDeviceHandler(db *gorm.DB) *AdminDeviceHandler { + return &AdminDeviceHandler{db: db} +} + +// APNSDeviceResponse represents an iOS device in API responses +type APNSDeviceResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Active bool `json:"active"` + UserID *uint `json:"user_id"` + Username *string `json:"username"` + DeviceID string `json:"device_id"` + RegistrationID string `json:"registration_id"` + DateCreated string `json:"date_created"` +} + +// GCMDeviceResponse represents an Android device in API responses +type GCMDeviceResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Active bool `json:"active"` + UserID *uint `json:"user_id"` + Username *string `json:"username"` + DeviceID string `json:"device_id"` + RegistrationID string `json:"registration_id"` + CloudMessageType string `json:"cloud_message_type"` + DateCreated string `json:"date_created"` +} + +// ListAPNS handles GET /api/admin/devices/apns +func (h *AdminDeviceHandler) ListAPNS(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var devices []models.APNSDevice + var total int64 + + query := h.db.Model(&models.APNSDevice{}).Preload("User") + + // Apply search + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("LEFT JOIN auth_user ON auth_user.id = push_notifications_apnsdevice.user_id"). + Where( + "push_notifications_apnsdevice.name ILIKE ? OR push_notifications_apnsdevice.device_id ILIKE ? OR auth_user.username ILIKE ?", + search, search, search, + ) + } + + query.Count(&total) + + sortBy := "date_created" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&devices).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch devices"}) + return + } + + responses := make([]APNSDeviceResponse, len(devices)) + for i, device := range devices { + var username *string + if device.User != nil { + username = &device.User.Username + } + responses[i] = APNSDeviceResponse{ + ID: device.ID, + Name: device.Name, + Active: device.Active, + UserID: device.UserID, + Username: username, + DeviceID: device.DeviceID, + RegistrationID: device.RegistrationID[:min(20, len(device.RegistrationID))] + "...", + DateCreated: device.DateCreated.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// ListGCM handles GET /api/admin/devices/gcm +func (h *AdminDeviceHandler) ListGCM(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var devices []models.GCMDevice + var total int64 + + query := h.db.Model(&models.GCMDevice{}).Preload("User") + + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("LEFT JOIN auth_user ON auth_user.id = push_notifications_gcmdevice.user_id"). + Where( + "push_notifications_gcmdevice.name ILIKE ? OR push_notifications_gcmdevice.device_id ILIKE ? OR auth_user.username ILIKE ?", + search, search, search, + ) + } + + query.Count(&total) + + sortBy := "date_created" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&devices).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch devices"}) + return + } + + responses := make([]GCMDeviceResponse, len(devices)) + for i, device := range devices { + var username *string + if device.User != nil { + username = &device.User.Username + } + responses[i] = GCMDeviceResponse{ + ID: device.ID, + Name: device.Name, + Active: device.Active, + UserID: device.UserID, + Username: username, + DeviceID: device.DeviceID, + RegistrationID: device.RegistrationID[:min(20, len(device.RegistrationID))] + "...", + CloudMessageType: device.CloudMessageType, + DateCreated: device.DateCreated.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// UpdateAPNS handles PUT /api/admin/devices/apns/:id +func (h *AdminDeviceHandler) UpdateAPNS(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 device models.APNSDevice + if err := h.db.First(&device, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch device"}) + return + } + + var req struct { + Active bool `json:"active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + device.Active = req.Active + if err := h.db.Save(&device).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Device updated successfully"}) +} + +// UpdateGCM handles PUT /api/admin/devices/gcm/:id +func (h *AdminDeviceHandler) UpdateGCM(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 device models.GCMDevice + if err := h.db.First(&device, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch device"}) + return + } + + var req struct { + Active bool `json:"active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + device.Active = req.Active + if err := h.db.Save(&device).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Device updated successfully"}) +} + +// DeleteAPNS handles DELETE /api/admin/devices/apns/:id +func (h *AdminDeviceHandler) DeleteAPNS(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 + } + + result := h.db.Delete(&models.APNSDevice{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete device"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Device deleted successfully"}) +} + +// DeleteGCM handles DELETE /api/admin/devices/gcm/:id +func (h *AdminDeviceHandler) DeleteGCM(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 + } + + result := h.db.Delete(&models.GCMDevice{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete device"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Device deleted successfully"}) +} + +// BulkDeleteAPNS handles DELETE /api/admin/devices/apns/bulk +func (h *AdminDeviceHandler) BulkDeleteAPNS(c *gin.Context) { + var req dto.BulkDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result := h.db.Where("id IN ?", req.IDs).Delete(&models.APNSDevice{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete devices"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Devices deleted successfully", "count": result.RowsAffected}) +} + +// BulkDeleteGCM handles DELETE /api/admin/devices/gcm/bulk +func (h *AdminDeviceHandler) BulkDeleteGCM(c *gin.Context) { + var req dto.BulkDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result := h.db.Where("id IN ?", req.IDs).Delete(&models.GCMDevice{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete devices"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Devices deleted successfully", "count": result.RowsAffected}) +} + +// GetStats handles GET /api/admin/devices/stats +func (h *AdminDeviceHandler) GetStats(c *gin.Context) { + var apnsTotal, apnsActive, gcmTotal, gcmActive int64 + + h.db.Model(&models.APNSDevice{}).Count(&apnsTotal) + h.db.Model(&models.APNSDevice{}).Where("active = ?", true).Count(&apnsActive) + h.db.Model(&models.GCMDevice{}).Count(&gcmTotal) + h.db.Model(&models.GCMDevice{}).Where("active = ?", true).Count(&gcmActive) + + c.JSON(http.StatusOK, gin.H{ + "apns": gin.H{ + "total": apnsTotal, + "active": apnsActive, + }, + "gcm": gin.H{ + "total": gcmTotal, + "active": gcmActive, + }, + "total": apnsTotal + gcmTotal, + }) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/admin/handlers/feature_benefit_handler.go b/internal/admin/handlers/feature_benefit_handler.go new file mode 100644 index 0000000..8b6937f --- /dev/null +++ b/internal/admin/handlers/feature_benefit_handler.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/treytartt/casera-api/internal/admin/dto" + "github.com/treytartt/casera-api/internal/models" +) + +// AdminFeatureBenefitHandler handles admin feature benefit management endpoints +type AdminFeatureBenefitHandler struct { + db *gorm.DB +} + +// NewAdminFeatureBenefitHandler creates a new admin feature benefit handler +func NewAdminFeatureBenefitHandler(db *gorm.DB) *AdminFeatureBenefitHandler { + return &AdminFeatureBenefitHandler{db: db} +} + +// FeatureBenefitResponse represents a feature benefit in API responses +type FeatureBenefitResponse struct { + ID uint `json:"id"` + FeatureName string `json:"feature_name"` + FreeTierText string `json:"free_tier_text"` + ProTierText string `json:"pro_tier_text"` + DisplayOrder int `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// List handles GET /api/admin/feature-benefits +func (h *AdminFeatureBenefitHandler) List(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var benefits []models.FeatureBenefit + var total int64 + + query := h.db.Model(&models.FeatureBenefit{}) + + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Where("feature_name ILIKE ? OR free_tier_text ILIKE ? OR pro_tier_text ILIKE ?", search, search, search) + } + + query.Count(&total) + + sortBy := "display_order" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&benefits).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefits"}) + return + } + + responses := make([]FeatureBenefitResponse, len(benefits)) + for i, b := range benefits { + responses[i] = FeatureBenefitResponse{ + ID: b.ID, + FeatureName: b.FeatureName, + FreeTierText: b.FreeTierText, + ProTierText: b.ProTierText, + DisplayOrder: b.DisplayOrder, + IsActive: b.IsActive, + CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: b.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/feature-benefits/:id +func (h *AdminFeatureBenefitHandler) Get(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 benefit models.FeatureBenefit + if err := h.db.First(&benefit, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefit"}) + return + } + + response := FeatureBenefitResponse{ + ID: benefit.ID, + FeatureName: benefit.FeatureName, + FreeTierText: benefit.FreeTierText, + ProTierText: benefit.ProTierText, + DisplayOrder: benefit.DisplayOrder, + IsActive: benefit.IsActive, + CreatedAt: benefit.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + c.JSON(http.StatusOK, response) +} + +// Create handles POST /api/admin/feature-benefits +func (h *AdminFeatureBenefitHandler) Create(c *gin.Context) { + var req struct { + FeatureName string `json:"feature_name" binding:"required"` + FreeTierText string `json:"free_tier_text" binding:"required"` + ProTierText string `json:"pro_tier_text" binding:"required"` + DisplayOrder int `json:"display_order"` + IsActive *bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + benefit := models.FeatureBenefit{ + FeatureName: req.FeatureName, + FreeTierText: req.FreeTierText, + ProTierText: req.ProTierText, + DisplayOrder: req.DisplayOrder, + IsActive: true, + } + + if req.IsActive != nil { + benefit.IsActive = *req.IsActive + } + + if err := h.db.Create(&benefit).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create feature benefit"}) + return + } + + c.JSON(http.StatusCreated, FeatureBenefitResponse{ + ID: benefit.ID, + FeatureName: benefit.FeatureName, + FreeTierText: benefit.FreeTierText, + ProTierText: benefit.ProTierText, + DisplayOrder: benefit.DisplayOrder, + IsActive: benefit.IsActive, + CreatedAt: benefit.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"), + }) +} + +// Update handles PUT /api/admin/feature-benefits/:id +func (h *AdminFeatureBenefitHandler) Update(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 benefit models.FeatureBenefit + if err := h.db.First(&benefit, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefit"}) + return + } + + var req struct { + FeatureName *string `json:"feature_name"` + FreeTierText *string `json:"free_tier_text"` + ProTierText *string `json:"pro_tier_text"` + DisplayOrder *int `json:"display_order"` + IsActive *bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.FeatureName != nil { + benefit.FeatureName = *req.FeatureName + } + if req.FreeTierText != nil { + benefit.FreeTierText = *req.FreeTierText + } + if req.ProTierText != nil { + benefit.ProTierText = *req.ProTierText + } + if req.DisplayOrder != nil { + benefit.DisplayOrder = *req.DisplayOrder + } + if req.IsActive != nil { + benefit.IsActive = *req.IsActive + } + + if err := h.db.Save(&benefit).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update feature benefit"}) + return + } + + c.JSON(http.StatusOK, FeatureBenefitResponse{ + ID: benefit.ID, + FeatureName: benefit.FeatureName, + FreeTierText: benefit.FreeTierText, + ProTierText: benefit.ProTierText, + DisplayOrder: benefit.DisplayOrder, + IsActive: benefit.IsActive, + CreatedAt: benefit.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"), + }) +} + +// Delete handles DELETE /api/admin/feature-benefits/:id +func (h *AdminFeatureBenefitHandler) Delete(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 + } + + result := h.db.Delete(&models.FeatureBenefit{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete feature benefit"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Feature benefit deleted successfully"}) +} diff --git a/internal/admin/handlers/password_reset_code_handler.go b/internal/admin/handlers/password_reset_code_handler.go new file mode 100644 index 0000000..621e7a2 --- /dev/null +++ b/internal/admin/handlers/password_reset_code_handler.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/treytartt/casera-api/internal/admin/dto" + "github.com/treytartt/casera-api/internal/models" +) + +// AdminPasswordResetCodeHandler handles admin password reset code management endpoints +type AdminPasswordResetCodeHandler struct { + db *gorm.DB +} + +// NewAdminPasswordResetCodeHandler creates a new admin password reset code handler +func NewAdminPasswordResetCodeHandler(db *gorm.DB) *AdminPasswordResetCodeHandler { + return &AdminPasswordResetCodeHandler{db: db} +} + +// PasswordResetCodeResponse represents a password reset code in API responses +type PasswordResetCodeResponse struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + ResetToken string `json:"reset_token"` + ExpiresAt string `json:"expires_at"` + Used bool `json:"used"` + Attempts int `json:"attempts"` + MaxAttempts int `json:"max_attempts"` + CreatedAt string `json:"created_at"` +} + +// List handles GET /api/admin/password-reset-codes +func (h *AdminPasswordResetCodeHandler) List(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var codes []models.PasswordResetCode + var total int64 + + query := h.db.Model(&models.PasswordResetCode{}).Preload("User") + + // Apply search (search by user info or token) + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("JOIN auth_user ON auth_user.id = user_passwordresetcode.user_id"). + Where( + "auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_passwordresetcode.reset_token ILIKE ?", + search, search, search, + ) + } + + // Get total count + query.Count(&total) + + // Apply sorting + sortBy := "created_at" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + + // Apply pagination + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&codes).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch password reset codes"}) + return + } + + // Build response + responses := make([]PasswordResetCodeResponse, len(codes)) + for i, code := range codes { + responses[i] = PasswordResetCodeResponse{ + ID: code.ID, + UserID: code.UserID, + Username: code.User.Username, + Email: code.User.Email, + ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:], // Truncate for display + ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"), + Used: code.Used, + Attempts: code.Attempts, + MaxAttempts: code.MaxAttempts, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/password-reset-codes/:id +func (h *AdminPasswordResetCodeHandler) Get(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 code models.PasswordResetCode + if err := h.db.Preload("User").First(&code, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Password reset code not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch password reset code"}) + return + } + + response := PasswordResetCodeResponse{ + ID: code.ID, + UserID: code.UserID, + Username: code.User.Username, + Email: code.User.Email, + ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:], + ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"), + Used: code.Used, + Attempts: code.Attempts, + MaxAttempts: code.MaxAttempts, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + + c.JSON(http.StatusOK, response) +} + +// Delete handles DELETE /api/admin/password-reset-codes/:id +func (h *AdminPasswordResetCodeHandler) Delete(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 + } + + result := h.db.Delete(&models.PasswordResetCode{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset code"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Password reset code not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Password reset code deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/password-reset-codes/bulk +func (h *AdminPasswordResetCodeHandler) BulkDelete(c *gin.Context) { + var req dto.BulkDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result := h.db.Where("id IN ?", req.IDs).Delete(&models.PasswordResetCode{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset codes"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Password reset codes deleted successfully", "count": result.RowsAffected}) +} diff --git a/internal/admin/handlers/promotion_handler.go b/internal/admin/handlers/promotion_handler.go new file mode 100644 index 0000000..bbec6ad --- /dev/null +++ b/internal/admin/handlers/promotion_handler.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/treytartt/casera-api/internal/admin/dto" + "github.com/treytartt/casera-api/internal/models" +) + +// AdminPromotionHandler handles admin promotion management endpoints +type AdminPromotionHandler struct { + db *gorm.DB +} + +// NewAdminPromotionHandler creates a new admin promotion handler +func NewAdminPromotionHandler(db *gorm.DB) *AdminPromotionHandler { + return &AdminPromotionHandler{db: db} +} + +// PromotionResponse represents a promotion in API responses +type PromotionResponse struct { + ID uint `json:"id"` + PromotionID string `json:"promotion_id"` + Title string `json:"title"` + Message string `json:"message"` + Link *string `json:"link"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + TargetTier string `json:"target_tier"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// List handles GET /api/admin/promotions +func (h *AdminPromotionHandler) List(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var promotions []models.Promotion + var total int64 + + query := h.db.Model(&models.Promotion{}) + + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Where("promotion_id ILIKE ? OR title ILIKE ? OR message ILIKE ?", search, search, search) + } + + query.Count(&total) + + sortBy := "created_at" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&promotions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotions"}) + return + } + + responses := make([]PromotionResponse, len(promotions)) + for i, p := range promotions { + responses[i] = PromotionResponse{ + ID: p.ID, + PromotionID: p.PromotionID, + Title: p.Title, + Message: p.Message, + Link: p.Link, + StartDate: p.StartDate.Format("2006-01-02T15:04:05Z"), + EndDate: p.EndDate.Format("2006-01-02T15:04:05Z"), + TargetTier: string(p.TargetTier), + IsActive: p.IsActive, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/promotions/:id +func (h *AdminPromotionHandler) Get(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 promotion models.Promotion + if err := h.db.First(&promotion, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotion"}) + return + } + + response := PromotionResponse{ + ID: promotion.ID, + PromotionID: promotion.PromotionID, + Title: promotion.Title, + Message: promotion.Message, + Link: promotion.Link, + StartDate: promotion.StartDate.Format("2006-01-02T15:04:05Z"), + EndDate: promotion.EndDate.Format("2006-01-02T15:04:05Z"), + TargetTier: string(promotion.TargetTier), + IsActive: promotion.IsActive, + CreatedAt: promotion.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + c.JSON(http.StatusOK, response) +} + +// Create handles POST /api/admin/promotions +func (h *AdminPromotionHandler) Create(c *gin.Context) { + var req struct { + PromotionID string `json:"promotion_id" binding:"required"` + Title string `json:"title" binding:"required"` + Message string `json:"message" binding:"required"` + Link *string `json:"link"` + StartDate string `json:"start_date" binding:"required"` + EndDate string `json:"end_date" binding:"required"` + TargetTier string `json:"target_tier"` + IsActive *bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + startDate, err := time.Parse("2006-01-02T15:04:05Z", req.StartDate) + if err != nil { + startDate, err = time.Parse("2006-01-02", req.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date format"}) + return + } + } + + endDate, err := time.Parse("2006-01-02T15:04:05Z", req.EndDate) + if err != nil { + endDate, err = time.Parse("2006-01-02", req.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"}) + return + } + } + + targetTier := models.TierFree + if req.TargetTier == "pro" { + targetTier = models.TierPro + } + + promotion := models.Promotion{ + PromotionID: req.PromotionID, + Title: req.Title, + Message: req.Message, + Link: req.Link, + StartDate: startDate, + EndDate: endDate, + TargetTier: targetTier, + IsActive: true, + } + + if req.IsActive != nil { + promotion.IsActive = *req.IsActive + } + + if err := h.db.Create(&promotion).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion"}) + return + } + + c.JSON(http.StatusCreated, PromotionResponse{ + ID: promotion.ID, + PromotionID: promotion.PromotionID, + Title: promotion.Title, + Message: promotion.Message, + Link: promotion.Link, + StartDate: promotion.StartDate.Format("2006-01-02T15:04:05Z"), + EndDate: promotion.EndDate.Format("2006-01-02T15:04:05Z"), + TargetTier: string(promotion.TargetTier), + IsActive: promotion.IsActive, + CreatedAt: promotion.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"), + }) +} + +// Update handles PUT /api/admin/promotions/:id +func (h *AdminPromotionHandler) Update(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 promotion models.Promotion + if err := h.db.First(&promotion, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotion"}) + return + } + + var req struct { + PromotionID *string `json:"promotion_id"` + Title *string `json:"title"` + Message *string `json:"message"` + Link *string `json:"link"` + StartDate *string `json:"start_date"` + EndDate *string `json:"end_date"` + TargetTier *string `json:"target_tier"` + IsActive *bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.PromotionID != nil { + promotion.PromotionID = *req.PromotionID + } + if req.Title != nil { + promotion.Title = *req.Title + } + if req.Message != nil { + promotion.Message = *req.Message + } + if req.Link != nil { + promotion.Link = req.Link + } + if req.StartDate != nil { + startDate, err := time.Parse("2006-01-02T15:04:05Z", *req.StartDate) + if err != nil { + startDate, err = time.Parse("2006-01-02", *req.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date format"}) + return + } + } + promotion.StartDate = startDate + } + if req.EndDate != nil { + endDate, err := time.Parse("2006-01-02T15:04:05Z", *req.EndDate) + if err != nil { + endDate, err = time.Parse("2006-01-02", *req.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"}) + return + } + } + promotion.EndDate = endDate + } + if req.TargetTier != nil { + if *req.TargetTier == "pro" { + promotion.TargetTier = models.TierPro + } else { + promotion.TargetTier = models.TierFree + } + } + if req.IsActive != nil { + promotion.IsActive = *req.IsActive + } + + if err := h.db.Save(&promotion).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update promotion"}) + return + } + + c.JSON(http.StatusOK, PromotionResponse{ + ID: promotion.ID, + PromotionID: promotion.PromotionID, + Title: promotion.Title, + Message: promotion.Message, + Link: promotion.Link, + StartDate: promotion.StartDate.Format("2006-01-02T15:04:05Z"), + EndDate: promotion.EndDate.Format("2006-01-02T15:04:05Z"), + TargetTier: string(promotion.TargetTier), + IsActive: promotion.IsActive, + CreatedAt: promotion.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"), + }) +} + +// Delete handles DELETE /api/admin/promotions/:id +func (h *AdminPromotionHandler) Delete(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 + } + + result := h.db.Delete(&models.Promotion{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete promotion"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Promotion deleted successfully"}) +} diff --git a/internal/admin/handlers/share_code_handler.go b/internal/admin/handlers/share_code_handler.go new file mode 100644 index 0000000..ee28bda --- /dev/null +++ b/internal/admin/handlers/share_code_handler.go @@ -0,0 +1,239 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/treytartt/casera-api/internal/admin/dto" + "github.com/treytartt/casera-api/internal/models" +) + +// AdminShareCodeHandler handles admin share code management endpoints +type AdminShareCodeHandler struct { + db *gorm.DB +} + +// NewAdminShareCodeHandler creates a new admin share code handler +func NewAdminShareCodeHandler(db *gorm.DB) *AdminShareCodeHandler { + return &AdminShareCodeHandler{db: db} +} + +// ShareCodeResponse represents a share code in API responses +type ShareCodeResponse struct { + ID uint `json:"id"` + ResidenceID uint `json:"residence_id"` + ResidenceName string `json:"residence_name"` + Code string `json:"code"` + CreatedByID uint `json:"created_by_id"` + CreatedBy string `json:"created_by"` + IsActive bool `json:"is_active"` + ExpiresAt *string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +// List handles GET /api/admin/share-codes +func (h *AdminShareCodeHandler) List(c *gin.Context) { + var filters dto.PaginationParams + if err := c.ShouldBindQuery(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var codes []models.ResidenceShareCode + var total int64 + + query := h.db.Model(&models.ResidenceShareCode{}). + Preload("Residence"). + Preload("CreatedBy") + + // Apply search (search by code or residence name) + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("JOIN residence_residence ON residence_residence.id = residence_residencesharecode.residence_id"). + Joins("JOIN auth_user ON auth_user.id = residence_residencesharecode.created_by_id"). + Where( + "residence_residencesharecode.code ILIKE ? OR residence_residence.name ILIKE ? OR auth_user.username ILIKE ?", + search, search, search, + ) + } + + // Get total count + query.Count(&total) + + // Apply sorting + sortBy := "created_at" + if filters.SortBy != "" { + sortBy = filters.SortBy + } + query = query.Order(sortBy + " " + filters.GetSortDir()) + + // Apply pagination + query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage()) + + if err := query.Find(&codes).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share codes"}) + return + } + + // Build response + responses := make([]ShareCodeResponse, len(codes)) + for i, code := range codes { + var expiresAt *string + if code.ExpiresAt != nil { + formatted := code.ExpiresAt.Format("2006-01-02T15:04:05Z") + expiresAt = &formatted + } + + responses[i] = ShareCodeResponse{ + ID: code.ID, + ResidenceID: code.ResidenceID, + ResidenceName: code.Residence.Name, + Code: code.Code, + CreatedByID: code.CreatedByID, + CreatedBy: code.CreatedBy.Username, + IsActive: code.IsActive, + ExpiresAt: expiresAt, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/share-codes/:id +func (h *AdminShareCodeHandler) Get(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 code models.ResidenceShareCode + if err := h.db.Preload("Residence").Preload("CreatedBy").First(&code, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share code"}) + return + } + + var expiresAt *string + if code.ExpiresAt != nil { + formatted := code.ExpiresAt.Format("2006-01-02T15:04:05Z") + expiresAt = &formatted + } + + response := ShareCodeResponse{ + ID: code.ID, + ResidenceID: code.ResidenceID, + ResidenceName: code.Residence.Name, + Code: code.Code, + CreatedByID: code.CreatedByID, + CreatedBy: code.CreatedBy.Username, + IsActive: code.IsActive, + ExpiresAt: expiresAt, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + + c.JSON(http.StatusOK, response) +} + +// Update handles PUT /api/admin/share-codes/:id +func (h *AdminShareCodeHandler) Update(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 code models.ResidenceShareCode + if err := h.db.First(&code, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share code"}) + return + } + + var req struct { + IsActive bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + code.IsActive = req.IsActive + + if err := h.db.Save(&code).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update share code"}) + return + } + + // Reload with relations + h.db.Preload("Residence").Preload("CreatedBy").First(&code, id) + + var expiresAt *string + if code.ExpiresAt != nil { + formatted := code.ExpiresAt.Format("2006-01-02T15:04:05Z") + expiresAt = &formatted + } + + response := ShareCodeResponse{ + ID: code.ID, + ResidenceID: code.ResidenceID, + ResidenceName: code.Residence.Name, + Code: code.Code, + CreatedByID: code.CreatedByID, + CreatedBy: code.CreatedBy.Username, + IsActive: code.IsActive, + ExpiresAt: expiresAt, + CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + + c.JSON(http.StatusOK, response) +} + +// Delete handles DELETE /api/admin/share-codes/:id +func (h *AdminShareCodeHandler) Delete(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 + } + + result := h.db.Delete(&models.ResidenceShareCode{}, id) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share code"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Share code deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/share-codes/bulk +func (h *AdminShareCodeHandler) BulkDelete(c *gin.Context) { + var req dto.BulkDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result := h.db.Where("id IN ?", req.IDs).Delete(&models.ResidenceShareCode{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share codes"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Share codes deleted successfully", "count": result.RowsAffected}) +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 0d49ec3..dc542c1 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -171,6 +171,74 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe completions.DELETE("/:id", completionHandler.Delete) } + // Confirmation code management + confirmationCodeHandler := handlers.NewAdminConfirmationCodeHandler(db) + confirmationCodes := protected.Group("/confirmation-codes") + { + confirmationCodes.GET("", confirmationCodeHandler.List) + confirmationCodes.DELETE("/bulk", confirmationCodeHandler.BulkDelete) + confirmationCodes.GET("/:id", confirmationCodeHandler.Get) + confirmationCodes.DELETE("/:id", confirmationCodeHandler.Delete) + } + + // Share code management + shareCodeHandler := handlers.NewAdminShareCodeHandler(db) + shareCodes := protected.Group("/share-codes") + { + shareCodes.GET("", shareCodeHandler.List) + shareCodes.DELETE("/bulk", shareCodeHandler.BulkDelete) + shareCodes.GET("/:id", shareCodeHandler.Get) + shareCodes.PUT("/:id", shareCodeHandler.Update) + shareCodes.DELETE("/:id", shareCodeHandler.Delete) + } + + // Password reset code management + passwordResetCodeHandler := handlers.NewAdminPasswordResetCodeHandler(db) + passwordResetCodes := protected.Group("/password-reset-codes") + { + passwordResetCodes.GET("", passwordResetCodeHandler.List) + passwordResetCodes.DELETE("/bulk", passwordResetCodeHandler.BulkDelete) + passwordResetCodes.GET("/:id", passwordResetCodeHandler.Get) + passwordResetCodes.DELETE("/:id", passwordResetCodeHandler.Delete) + } + + // Push notification devices management + deviceHandler := handlers.NewAdminDeviceHandler(db) + devices := protected.Group("/devices") + { + devices.GET("/stats", deviceHandler.GetStats) + devices.GET("/apns", deviceHandler.ListAPNS) + devices.DELETE("/apns/bulk", deviceHandler.BulkDeleteAPNS) + devices.PUT("/apns/:id", deviceHandler.UpdateAPNS) + devices.DELETE("/apns/:id", deviceHandler.DeleteAPNS) + devices.GET("/gcm", deviceHandler.ListGCM) + devices.DELETE("/gcm/bulk", deviceHandler.BulkDeleteGCM) + devices.PUT("/gcm/:id", deviceHandler.UpdateGCM) + devices.DELETE("/gcm/:id", deviceHandler.DeleteGCM) + } + + // Feature benefits management + featureBenefitHandler := handlers.NewAdminFeatureBenefitHandler(db) + featureBenefits := protected.Group("/feature-benefits") + { + featureBenefits.GET("", featureBenefitHandler.List) + featureBenefits.POST("", featureBenefitHandler.Create) + featureBenefits.GET("/:id", featureBenefitHandler.Get) + featureBenefits.PUT("/:id", featureBenefitHandler.Update) + featureBenefits.DELETE("/:id", featureBenefitHandler.Delete) + } + + // Promotions management + promotionHandler := handlers.NewAdminPromotionHandler(db) + promotions := protected.Group("/promotions") + { + promotions.GET("", promotionHandler.List) + promotions.POST("", promotionHandler.Create) + promotions.GET("/:id", promotionHandler.Get) + promotions.PUT("/:id", promotionHandler.Update) + promotions.DELETE("/:id", promotionHandler.Delete) + } + // Lookup tables management lookupHandler := handlers.NewAdminLookupHandler(db)