From 0ea0c766ea8affbba0f89e42fef4ad976e283709 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 7 Dec 2025 21:08:50 -0600 Subject: [PATCH] Add admin CRUD for UserProfile, AppleSocialAuth, CompletionImage, DocumentImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete admin interface coverage for all database models: - New Go handlers with list, get, update, delete, bulk delete endpoints - New Next.js pages with search, pagination, and bulk operations - Updated sidebar navigation with new menu items 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../(dashboard)/apple-social-auth/page.tsx | 309 ++++++++++++++++ .../(dashboard)/completion-images/page.tsx | 329 ++++++++++++++++++ .../app/(dashboard)/document-images/page.tsx | 329 ++++++++++++++++++ .../app/(dashboard)/user-profiles/page.tsx | 301 ++++++++++++++++ admin/src/components/app-sidebar.tsx | 8 + admin/src/lib/api.ts | 246 +++++++++++++ .../handlers/apple_social_auth_handler.go | 233 +++++++++++++ .../handlers/completion_image_handler.go | 262 ++++++++++++++ .../admin/handlers/document_image_handler.go | 264 ++++++++++++++ .../admin/handlers/user_profile_handler.go | 263 ++++++++++++++ internal/admin/routes.go | 48 +++ 11 files changed, 2592 insertions(+) create mode 100644 admin/src/app/(dashboard)/apple-social-auth/page.tsx create mode 100644 admin/src/app/(dashboard)/completion-images/page.tsx create mode 100644 admin/src/app/(dashboard)/document-images/page.tsx create mode 100644 admin/src/app/(dashboard)/user-profiles/page.tsx create mode 100644 internal/admin/handlers/apple_social_auth_handler.go create mode 100644 internal/admin/handlers/completion_image_handler.go create mode 100644 internal/admin/handlers/document_image_handler.go create mode 100644 internal/admin/handlers/user_profile_handler.go diff --git a/admin/src/app/(dashboard)/apple-social-auth/page.tsx b/admin/src/app/(dashboard)/apple-social-auth/page.tsx new file mode 100644 index 0000000..aa385c0 --- /dev/null +++ b/admin/src/app/(dashboard)/apple-social-auth/page.tsx @@ -0,0 +1,309 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { Apple, Trash2, Search, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react'; +import { toast } from 'sonner'; + +import { appleSocialAuthApi, type AppleSocialAuth, type AppleSocialAuthListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +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 AppleSocialAuthPage() { + 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: ['apple-social-auth', params], + queryFn: () => appleSocialAuthApi.list(params), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => appleSocialAuthApi.delete(id), + onSuccess: () => { + toast.success('Apple Sign In entry deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['apple-social-auth'] }); + }, + onError: () => { + toast.error('Failed to delete Apple Sign In entry'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => appleSocialAuthApi.bulkDelete(ids), + onSuccess: () => { + toast.success('Apple Sign In entries deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['apple-social-auth'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete Apple Sign In entries'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((a) => a.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load Apple Sign In entries
+
+ ); + } + + return ( +
+
+
+

Apple Sign In

+

+ Manage Apple Sign In linked accounts +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Entries? + + This will permanently delete {selectedRows.length} Apple Sign In link(s). Users will need to re-link their Apple accounts. + + + + 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 + Apple ID + Apple Email + Private + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No Apple Sign In entries found + + + ) : ( + data?.data?.map((entry) => ( + + + + handleSelectRow(entry.id, checked as boolean) + } + /> + + + + {entry.username} + + + + + {entry.apple_id.substring(0, 20)}... + + + {entry.email || '-'} + + {entry.is_private_email ? ( + + + + ) : ( + + + + )} + + + {new Date(entry.created_at).toLocaleDateString()} + + + + + + + + + Delete Apple Sign In Link? + + This will unlink the Apple account from {entry.username}. They will need to re-link their Apple account. + + + + Cancel + deleteMutation.mutate(entry.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} entries +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/app/(dashboard)/completion-images/page.tsx b/admin/src/app/(dashboard)/completion-images/page.tsx new file mode 100644 index 0000000..494739e --- /dev/null +++ b/admin/src/app/(dashboard)/completion-images/page.tsx @@ -0,0 +1,329 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { Image, Trash2, Search, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; + +import { completionImagesApi, type CompletionImage, type CompletionImageListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +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 CompletionImagesPage() { + 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: ['completion-images', params], + queryFn: () => completionImagesApi.list(params), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => completionImagesApi.delete(id), + onSuccess: () => { + toast.success('Completion image deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['completion-images'] }); + }, + onError: () => { + toast.error('Failed to delete completion image'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => completionImagesApi.bulkDelete(ids), + onSuccess: () => { + toast.success('Completion images deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['completion-images'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete completion images'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((img) => img.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load completion images
+
+ ); + } + + return ( +
+
+
+

Completion Images

+

+ Manage images attached to task completions +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Images? + + This will permanently delete {selectedRows.length} image(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} + /> + + Preview + Task + Caption + Completion ID + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No completion images found + + + ) : ( + data?.data?.map((img) => ( + + + + handleSelectRow(img.id, checked as boolean) + } + /> + + + {img.image_url ? ( + + {img.caption + + ) : ( +
+ +
+ )} +
+ + + {img.task_title || `Task #${img.task_id}`} + + + + + {img.caption || '-'} + + + + + #{img.completion_id} + + + + {new Date(img.created_at).toLocaleDateString()} + + +
+ {img.image_url && ( + + + + )} + + + + + + + Delete Image? + + This will permanently delete this completion image. + + + + Cancel + deleteMutation.mutate(img.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} images +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/app/(dashboard)/document-images/page.tsx b/admin/src/app/(dashboard)/document-images/page.tsx new file mode 100644 index 0000000..20dd346 --- /dev/null +++ b/admin/src/app/(dashboard)/document-images/page.tsx @@ -0,0 +1,329 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { ImagePlus, Trash2, Search, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; + +import { documentImagesApi, type DocumentImage, type DocumentImageListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +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 DocumentImagesPage() { + 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: ['document-images', params], + queryFn: () => documentImagesApi.list(params), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => documentImagesApi.delete(id), + onSuccess: () => { + toast.success('Document image deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['document-images'] }); + }, + onError: () => { + toast.error('Failed to delete document image'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => documentImagesApi.bulkDelete(ids), + onSuccess: () => { + toast.success('Document images deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['document-images'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete document images'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((img) => img.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load document images
+
+ ); + } + + return ( +
+
+
+

Document Images

+

+ Manage images attached to documents +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Images? + + This will permanently delete {selectedRows.length} image(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} + /> + + Preview + Document + Residence + Caption + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No document images found + + + ) : ( + data?.data?.map((img) => ( + + + + handleSelectRow(img.id, checked as boolean) + } + /> + + + {img.image_url ? ( + + {img.caption + + ) : ( +
+ +
+ )} +
+ + + {img.document_title || `Document #${img.document_id}`} + + + + + {img.residence_name || `#${img.residence_id}`} + + + + + {img.caption || '-'} + + + + {new Date(img.created_at).toLocaleDateString()} + + +
+ {img.image_url && ( + + + + )} + + + + + + + Delete Image? + + This will permanently delete this document image. + + + + Cancel + deleteMutation.mutate(img.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} images +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/app/(dashboard)/user-profiles/page.tsx b/admin/src/app/(dashboard)/user-profiles/page.tsx new file mode 100644 index 0000000..aec6685 --- /dev/null +++ b/admin/src/app/(dashboard)/user-profiles/page.tsx @@ -0,0 +1,301 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { UserCircle, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { userProfilesApi, type UserProfile, type UserProfileListParams } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +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 UserProfilesPage() { + 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: ['user-profiles', params], + queryFn: () => userProfilesApi.list(params), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => userProfilesApi.delete(id), + onSuccess: () => { + toast.success('User profile deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['user-profiles'] }); + }, + onError: () => { + toast.error('Failed to delete user profile'); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: number[]) => userProfilesApi.bulkDelete(ids), + onSuccess: () => { + toast.success('User profiles deleted successfully'); + queryClient.invalidateQueries({ queryKey: ['user-profiles'] }); + setSelectedRows([]); + }, + onError: () => { + toast.error('Failed to delete user profiles'); + }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setParams({ ...params, search, page: 1 }); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked && data?.data) { + setSelectedRows(data.data.map((p) => p.id)); + } else { + setSelectedRows([]); + } + }; + + const handleSelectRow = (id: number, checked: boolean) => { + if (checked) { + setSelectedRows([...selectedRows, id]); + } else { + setSelectedRows(selectedRows.filter((rowId) => rowId !== id)); + } + }; + + const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0; + + if (error) { + return ( +
+
Failed to load user profiles
+
+ ); + } + + return ( +
+
+
+

User Profiles

+

+ Manage user profile information +

+
+
+ + {/* Search and Bulk Actions */} +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + {selectedRows.length > 0 && ( + + + + + + + Delete Selected Profiles? + + This will permanently delete {selectedRows.length} profile(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 + Phone + Verified + Created + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : data?.data?.length === 0 ? ( + + + No user profiles found + + + ) : ( + data?.data?.map((profile) => ( + + + + handleSelectRow(profile.id, checked as boolean) + } + /> + + + + {profile.username} + + + {profile.email} + {profile.phone_number || '-'} + + {profile.verified ? ( + + ) : ( + + )} + + + {new Date(profile.created_at).toLocaleDateString()} + + + + + + + + + Delete Profile? + + This will permanently delete the profile for {profile.username}. + + + + Cancel + deleteMutation.mutate(profile.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} profiles +
+
+ + +
+
+ )} +
+ ); +} diff --git a/admin/src/components/app-sidebar.tsx b/admin/src/components/app-sidebar.tsx index e312b73..563e0d1 100644 --- a/admin/src/components/app-sidebar.tsx +++ b/admin/src/components/app-sidebar.tsx @@ -24,6 +24,10 @@ import { KeyRound, Smartphone, LayoutTemplate, + UserCircle, + Apple, + Image, + ImagePlus, } from 'lucide-react'; import { useRouter, usePathname } from 'next/navigation'; import { useAuthStore } from '@/store/auth'; @@ -44,6 +48,8 @@ import { Button } from '@/components/ui/button'; const menuItems = [ { title: 'Dashboard', url: '/admin/', icon: Home }, { title: 'Users', url: '/admin/users', icon: Users }, + { title: 'User Profiles', url: '/admin/user-profiles', icon: UserCircle }, + { title: 'Apple Sign In', url: '/admin/apple-social-auth', icon: Apple }, { 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 }, @@ -51,8 +57,10 @@ const menuItems = [ { title: 'Share Codes', url: '/admin/share-codes', icon: Share2 }, { title: 'Tasks', url: '/admin/tasks', icon: ClipboardList }, { title: 'Completions', url: '/admin/completions', icon: CheckCircle }, + { title: 'Completion Images', url: '/admin/completion-images', icon: Image }, { title: 'Contractors', url: '/admin/contractors', icon: Wrench }, { title: 'Documents', url: '/admin/documents', icon: FileText }, + { title: 'Document Images', url: '/admin/document-images', icon: ImagePlus }, { title: 'Notifications', url: '/admin/notifications', icon: Bell }, { title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing }, { title: 'Devices', url: '/admin/devices', icon: Smartphone }, diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index b2f5eab..0f2261f 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -1248,4 +1248,250 @@ export const taskTemplatesApi = { }, }; +// User Profile Types +export interface UserProfile { + id: number; + user_id: number; + username: string; + email: string; + verified: boolean; + bio: string; + phone_number: string; + date_of_birth: string | null; + profile_picture: string; + created_at: string; + updated_at: string; +} + +export interface UserProfileListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; +} + +export interface UpdateUserProfileRequest { + verified?: boolean; + bio?: string; + phone_number?: string; + date_of_birth?: string; + profile_picture?: string; +} + +// User Profiles API +export const userProfilesApi = { + list: async (params?: UserProfileListParams): Promise> => { + const response = await api.get>('/user-profiles', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/user-profiles/${id}`); + return response.data; + }, + + getByUser: async (userId: number): Promise => { + const response = await api.get(`/user-profiles/user/${userId}`); + return response.data; + }, + + update: async (id: number, data: UpdateUserProfileRequest): Promise => { + const response = await api.put(`/user-profiles/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/user-profiles/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/user-profiles/bulk', { data: { ids } }); + }, +}; + +// Apple Social Auth Types +export interface AppleSocialAuth { + id: number; + user_id: number; + username: string; + user_email: string; + apple_id: string; + email: string; + is_private_email: boolean; + created_at: string; + updated_at: string; +} + +export interface AppleSocialAuthListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; +} + +export interface UpdateAppleSocialAuthRequest { + email?: string; + is_private_email?: boolean; +} + +// Apple Social Auth API +export const appleSocialAuthApi = { + list: async (params?: AppleSocialAuthListParams): Promise> => { + const response = await api.get>('/apple-social-auth', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/apple-social-auth/${id}`); + return response.data; + }, + + getByUser: async (userId: number): Promise => { + const response = await api.get(`/apple-social-auth/user/${userId}`); + return response.data; + }, + + update: async (id: number, data: UpdateAppleSocialAuthRequest): Promise => { + const response = await api.put(`/apple-social-auth/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/apple-social-auth/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/apple-social-auth/bulk', { data: { ids } }); + }, +}; + +// Completion Image Types (for standalone management) +export interface CompletionImage { + id: number; + completion_id: number; + task_id: number; + task_title: string; + image_url: string; + caption: string; + created_at: string; + updated_at: string; +} + +export interface CompletionImageListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; + completion_id?: number; +} + +export interface CreateCompletionImageRequest { + completion_id: number; + image_url: string; + caption?: string; +} + +export interface UpdateCompletionImageRequest { + image_url?: string; + caption?: string; +} + +// Completion Images API +export const completionImagesApi = { + list: async (params?: CompletionImageListParams): Promise> => { + const response = await api.get>('/completion-images', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/completion-images/${id}`); + return response.data; + }, + + create: async (data: CreateCompletionImageRequest): Promise => { + const response = await api.post('/completion-images', data); + return response.data; + }, + + update: async (id: number, data: UpdateCompletionImageRequest): Promise => { + const response = await api.put(`/completion-images/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/completion-images/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/completion-images/bulk', { data: { ids } }); + }, +}; + +// Document Image Types +export interface DocumentImage { + id: number; + document_id: number; + document_title: string; + residence_id: number; + residence_name: string; + image_url: string; + caption: string; + created_at: string; + updated_at: string; +} + +export interface DocumentImageListParams { + page?: number; + per_page?: number; + search?: string; + sort_by?: string; + sort_dir?: 'asc' | 'desc'; + document_id?: number; +} + +export interface CreateDocumentImageRequest { + document_id: number; + image_url: string; + caption?: string; +} + +export interface UpdateDocumentImageRequest { + image_url?: string; + caption?: string; +} + +// Document Images API +export const documentImagesApi = { + list: async (params?: DocumentImageListParams): Promise> => { + const response = await api.get>('/document-images', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/document-images/${id}`); + return response.data; + }, + + create: async (data: CreateDocumentImageRequest): Promise => { + const response = await api.post('/document-images', data); + return response.data; + }, + + update: async (id: number, data: UpdateDocumentImageRequest): Promise => { + const response = await api.put(`/document-images/${id}`, data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/document-images/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/document-images/bulk', { data: { ids } }); + }, +}; + export default api; diff --git a/internal/admin/handlers/apple_social_auth_handler.go b/internal/admin/handlers/apple_social_auth_handler.go new file mode 100644 index 0000000..a7775ac --- /dev/null +++ b/internal/admin/handlers/apple_social_auth_handler.go @@ -0,0 +1,233 @@ +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" +) + +// AdminAppleSocialAuthHandler handles admin Apple social auth management endpoints +type AdminAppleSocialAuthHandler struct { + db *gorm.DB +} + +// NewAdminAppleSocialAuthHandler creates a new admin Apple social auth handler +func NewAdminAppleSocialAuthHandler(db *gorm.DB) *AdminAppleSocialAuthHandler { + return &AdminAppleSocialAuthHandler{db: db} +} + +// AppleSocialAuthResponse represents the response for an Apple social auth entry +type AppleSocialAuthResponse struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + UserEmail string `json:"user_email"` + AppleID string `json:"apple_id"` + Email string `json:"email"` + IsPrivateEmail bool `json:"is_private_email"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UpdateAppleSocialAuthRequest represents the request to update an Apple social auth entry +type UpdateAppleSocialAuthRequest struct { + Email *string `json:"email"` + IsPrivateEmail *bool `json:"is_private_email"` +} + +// List handles GET /api/admin/apple-social-auth +func (h *AdminAppleSocialAuthHandler) 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 entries []models.AppleSocialAuth + var total int64 + + query := h.db.Model(&models.AppleSocialAuth{}).Preload("User") + + // Apply search + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("JOIN auth_user ON auth_user.id = user_applesocialauth.user_id"). + Where("user_applesocialauth.apple_id ILIKE ? OR user_applesocialauth.email ILIKE ? OR auth_user.username ILIKE ? OR auth_user.email ILIKE ?", + search, 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(&entries).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entries"}) + return + } + + // Build response + responses := make([]AppleSocialAuthResponse, len(entries)) + for i, entry := range entries { + responses[i] = h.toResponse(&entry) + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/apple-social-auth/:id +func (h *AdminAppleSocialAuthHandler) 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 entry models.AppleSocialAuth + if err := h.db.Preload("User").First(&entry, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"}) + return + } + + c.JSON(http.StatusOK, h.toResponse(&entry)) +} + +// GetByUser handles GET /api/admin/apple-social-auth/user/:user_id +func (h *AdminAppleSocialAuthHandler) GetByUser(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var entry models.AppleSocialAuth + if err := h.db.Preload("User").Where("user_id = ?", userID).First(&entry).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found for user"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"}) + return + } + + c.JSON(http.StatusOK, h.toResponse(&entry)) +} + +// Update handles PUT /api/admin/apple-social-auth/:id +func (h *AdminAppleSocialAuthHandler) 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 entry models.AppleSocialAuth + if err := h.db.First(&entry, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"}) + return + } + + var req UpdateAppleSocialAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Email != nil { + entry.Email = *req.Email + } + if req.IsPrivateEmail != nil { + entry.IsPrivateEmail = *req.IsPrivateEmail + } + + if err := h.db.Save(&entry).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update Apple social auth entry"}) + return + } + + h.db.Preload("User").First(&entry, id) + c.JSON(http.StatusOK, h.toResponse(&entry)) +} + +// Delete handles DELETE /api/admin/apple-social-auth/:id +func (h *AdminAppleSocialAuthHandler) 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 + } + + var entry models.AppleSocialAuth + if err := h.db.First(&entry, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"}) + return + } + + if err := h.db.Delete(&entry).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth entry"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Apple social auth entry deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/apple-social-auth/bulk +func (h *AdminAppleSocialAuthHandler) 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 + } + + if err := h.db.Where("id IN ?", req.IDs).Delete(&models.AppleSocialAuth{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth entries"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Apple social auth entries deleted successfully", "count": len(req.IDs)}) +} + +// toResponse converts an AppleSocialAuth model to AppleSocialAuthResponse +func (h *AdminAppleSocialAuthHandler) toResponse(entry *models.AppleSocialAuth) AppleSocialAuthResponse { + response := AppleSocialAuthResponse{ + ID: entry.ID, + UserID: entry.UserID, + AppleID: entry.AppleID, + Email: entry.Email, + IsPrivateEmail: entry.IsPrivateEmail, + CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: entry.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + if entry.User.ID != 0 { + response.Username = entry.User.Username + response.UserEmail = entry.User.Email + } + + return response +} diff --git a/internal/admin/handlers/completion_image_handler.go b/internal/admin/handlers/completion_image_handler.go new file mode 100644 index 0000000..51e9350 --- /dev/null +++ b/internal/admin/handlers/completion_image_handler.go @@ -0,0 +1,262 @@ +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" +) + +// AdminCompletionImageHandler handles admin task completion image management endpoints +type AdminCompletionImageHandler struct { + db *gorm.DB +} + +// NewAdminCompletionImageHandler creates a new admin completion image handler +func NewAdminCompletionImageHandler(db *gorm.DB) *AdminCompletionImageHandler { + return &AdminCompletionImageHandler{db: db} +} + +// AdminCompletionImageResponse represents the response for a task completion image in admin +type AdminCompletionImageResponse struct { + ID uint `json:"id"` + CompletionID uint `json:"completion_id"` + TaskID uint `json:"task_id"` + TaskTitle string `json:"task_title"` + ImageURL string `json:"image_url"` + Caption string `json:"caption"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CreateCompletionImageRequest represents the request to create a completion image +type CreateCompletionImageRequest struct { + CompletionID uint `json:"completion_id" binding:"required"` + ImageURL string `json:"image_url" binding:"required"` + Caption string `json:"caption"` +} + +// UpdateCompletionImageRequest represents the request to update a completion image +type UpdateCompletionImageRequest struct { + ImageURL *string `json:"image_url"` + Caption *string `json:"caption"` +} + +// List handles GET /api/admin/completion-images +func (h *AdminCompletionImageHandler) 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 + } + + // Optional completion_id filter + completionIDStr := c.Query("completion_id") + + var images []models.TaskCompletionImage + var total int64 + + query := h.db.Model(&models.TaskCompletionImage{}) + + // Apply completion_id filter if provided + if completionIDStr != "" { + completionID, err := strconv.ParseUint(completionIDStr, 10, 32) + if err == nil { + query = query.Where("completion_id = ?", completionID) + } + } + + // Apply search + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Where("image_url ILIKE ? OR caption ILIKE ?", 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(&images).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion images"}) + return + } + + // Build response with task info + responses := make([]AdminCompletionImageResponse, len(images)) + for i, image := range images { + responses[i] = h.toResponse(&image) + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/completion-images/:id +func (h *AdminCompletionImageHandler) Get(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + var image models.TaskCompletionImage + if err := h.db.First(&image, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"}) + return + } + + c.JSON(http.StatusOK, h.toResponse(&image)) +} + +// Create handles POST /api/admin/completion-images +func (h *AdminCompletionImageHandler) Create(c *gin.Context) { + var req CreateCompletionImageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify completion exists + var completion models.TaskCompletion + if err := h.db.First(&completion, req.CompletionID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusBadRequest, gin.H{"error": "Task completion not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify completion"}) + return + } + + image := models.TaskCompletionImage{ + CompletionID: req.CompletionID, + ImageURL: req.ImageURL, + Caption: req.Caption, + } + + if err := h.db.Create(&image).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create completion image"}) + return + } + + c.JSON(http.StatusCreated, h.toResponse(&image)) +} + +// Update handles PUT /api/admin/completion-images/:id +func (h *AdminCompletionImageHandler) Update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + var image models.TaskCompletionImage + if err := h.db.First(&image, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"}) + return + } + + var req UpdateCompletionImageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.ImageURL != nil { + image.ImageURL = *req.ImageURL + } + if req.Caption != nil { + image.Caption = *req.Caption + } + + if err := h.db.Save(&image).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update completion image"}) + return + } + + c.JSON(http.StatusOK, h.toResponse(&image)) +} + +// Delete handles DELETE /api/admin/completion-images/:id +func (h *AdminCompletionImageHandler) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + var image models.TaskCompletionImage + if err := h.db.First(&image, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"}) + return + } + + if err := h.db.Delete(&image).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion image"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Completion image deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/completion-images/bulk +func (h *AdminCompletionImageHandler) 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 + } + + if err := h.db.Where("id IN ?", req.IDs).Delete(&models.TaskCompletionImage{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion images"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Completion images deleted successfully", "count": len(req.IDs)}) +} + +// toResponse converts a TaskCompletionImage model to AdminCompletionImageResponse +func (h *AdminCompletionImageHandler) toResponse(image *models.TaskCompletionImage) AdminCompletionImageResponse { + response := AdminCompletionImageResponse{ + ID: image.ID, + CompletionID: image.CompletionID, + ImageURL: image.ImageURL, + Caption: image.Caption, + CreatedAt: image.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: image.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + // Get task info via completion + var completion models.TaskCompletion + if err := h.db.Preload("Task").First(&completion, image.CompletionID).Error; err == nil { + response.TaskID = completion.TaskID + if completion.Task.ID != 0 { + response.TaskTitle = completion.Task.Title + } + } + + return response +} diff --git a/internal/admin/handlers/document_image_handler.go b/internal/admin/handlers/document_image_handler.go new file mode 100644 index 0000000..7f74581 --- /dev/null +++ b/internal/admin/handlers/document_image_handler.go @@ -0,0 +1,264 @@ +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" +) + +// AdminDocumentImageHandler handles admin document image management endpoints +type AdminDocumentImageHandler struct { + db *gorm.DB +} + +// NewAdminDocumentImageHandler creates a new admin document image handler +func NewAdminDocumentImageHandler(db *gorm.DB) *AdminDocumentImageHandler { + return &AdminDocumentImageHandler{db: db} +} + +// DocumentImageResponse represents the response for a document image +type DocumentImageResponse struct { + ID uint `json:"id"` + DocumentID uint `json:"document_id"` + DocumentTitle string `json:"document_title"` + ResidenceID uint `json:"residence_id"` + ResidenceName string `json:"residence_name"` + ImageURL string `json:"image_url"` + Caption string `json:"caption"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CreateDocumentImageRequest represents the request to create a document image +type CreateDocumentImageRequest struct { + DocumentID uint `json:"document_id" binding:"required"` + ImageURL string `json:"image_url" binding:"required"` + Caption string `json:"caption"` +} + +// UpdateDocumentImageRequest represents the request to update a document image +type UpdateDocumentImageRequest struct { + ImageURL *string `json:"image_url"` + Caption *string `json:"caption"` +} + +// List handles GET /api/admin/document-images +func (h *AdminDocumentImageHandler) 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 + } + + // Optional document_id filter + documentIDStr := c.Query("document_id") + + var images []models.DocumentImage + var total int64 + + query := h.db.Model(&models.DocumentImage{}) + + // Apply document_id filter if provided + if documentIDStr != "" { + documentID, err := strconv.ParseUint(documentIDStr, 10, 32) + if err == nil { + query = query.Where("document_id = ?", documentID) + } + } + + // Apply search + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Where("image_url ILIKE ? OR caption ILIKE ?", 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(&images).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document images"}) + return + } + + // Build response with document info + responses := make([]DocumentImageResponse, len(images)) + for i, image := range images { + responses[i] = h.toResponse(&image) + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/document-images/:id +func (h *AdminDocumentImageHandler) Get(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + var image models.DocumentImage + if err := h.db.First(&image, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"}) + return + } + + c.JSON(http.StatusOK, h.toResponse(&image)) +} + +// Create handles POST /api/admin/document-images +func (h *AdminDocumentImageHandler) Create(c *gin.Context) { + var req CreateDocumentImageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify document exists + var document models.Document + if err := h.db.First(&document, req.DocumentID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusBadRequest, gin.H{"error": "Document not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify document"}) + return + } + + image := models.DocumentImage{ + DocumentID: req.DocumentID, + ImageURL: req.ImageURL, + Caption: req.Caption, + } + + if err := h.db.Create(&image).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document image"}) + return + } + + c.JSON(http.StatusCreated, h.toResponse(&image)) +} + +// Update handles PUT /api/admin/document-images/:id +func (h *AdminDocumentImageHandler) Update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + var image models.DocumentImage + if err := h.db.First(&image, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"}) + return + } + + var req UpdateDocumentImageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.ImageURL != nil { + image.ImageURL = *req.ImageURL + } + if req.Caption != nil { + image.Caption = *req.Caption + } + + if err := h.db.Save(&image).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update document image"}) + return + } + + c.JSON(http.StatusOK, h.toResponse(&image)) +} + +// Delete handles DELETE /api/admin/document-images/:id +func (h *AdminDocumentImageHandler) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"}) + return + } + + var image models.DocumentImage + if err := h.db.First(&image, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"}) + return + } + + if err := h.db.Delete(&image).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document image"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Document image deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/document-images/bulk +func (h *AdminDocumentImageHandler) 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 + } + + if err := h.db.Where("id IN ?", req.IDs).Delete(&models.DocumentImage{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document images"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Document images deleted successfully", "count": len(req.IDs)}) +} + +// toResponse converts a DocumentImage model to DocumentImageResponse +func (h *AdminDocumentImageHandler) toResponse(image *models.DocumentImage) DocumentImageResponse { + response := DocumentImageResponse{ + ID: image.ID, + DocumentID: image.DocumentID, + ImageURL: image.ImageURL, + Caption: image.Caption, + CreatedAt: image.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: image.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + // Get document info + var document models.Document + if err := h.db.Preload("Residence").First(&document, image.DocumentID).Error; err == nil { + response.DocumentTitle = document.Title + response.ResidenceID = document.ResidenceID + if document.Residence.ID != 0 { + response.ResidenceName = document.Residence.Name + } + } + + return response +} diff --git a/internal/admin/handlers/user_profile_handler.go b/internal/admin/handlers/user_profile_handler.go new file mode 100644 index 0000000..7a44ac3 --- /dev/null +++ b/internal/admin/handlers/user_profile_handler.go @@ -0,0 +1,263 @@ +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" +) + +// AdminUserProfileHandler handles admin user profile management endpoints +type AdminUserProfileHandler struct { + db *gorm.DB +} + +// NewAdminUserProfileHandler creates a new admin user profile handler +func NewAdminUserProfileHandler(db *gorm.DB) *AdminUserProfileHandler { + return &AdminUserProfileHandler{db: db} +} + +// UserProfileResponse represents the response for a user profile +type UserProfileResponse struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + Verified bool `json:"verified"` + Bio string `json:"bio"` + PhoneNumber string `json:"phone_number"` + DateOfBirth *string `json:"date_of_birth"` + ProfilePicture string `json:"profile_picture"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UpdateUserProfileRequest represents the request to update a user profile +type UpdateUserProfileRequest struct { + Verified *bool `json:"verified"` + Bio *string `json:"bio"` + PhoneNumber *string `json:"phone_number"` + DateOfBirth *string `json:"date_of_birth"` + ProfilePicture *string `json:"profile_picture"` +} + +// List handles GET /api/admin/user-profiles +func (h *AdminUserProfileHandler) 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 profiles []models.UserProfile + var total int64 + + query := h.db.Model(&models.UserProfile{}).Preload("User") + + // Apply search + if filters.Search != "" { + search := "%" + filters.Search + "%" + query = query.Joins("JOIN auth_user ON auth_user.id = user_userprofile.user_id"). + Where("auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_userprofile.phone_number 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(&profiles).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profiles"}) + return + } + + // Build response + responses := make([]UserProfileResponse, len(profiles)) + for i, profile := range profiles { + responses[i] = h.toProfileResponse(&profile) + } + + c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage())) +} + +// Get handles GET /api/admin/user-profiles/:id +func (h *AdminUserProfileHandler) Get(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"}) + return + } + + var profile models.UserProfile + if err := h.db.Preload("User").First(&profile, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"}) + return + } + + c.JSON(http.StatusOK, h.toProfileResponse(&profile)) +} + +// GetByUser handles GET /api/admin/user-profiles/user/:user_id +func (h *AdminUserProfileHandler) GetByUser(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var profile models.UserProfile + if err := h.db.Preload("User").Where("user_id = ?", userID).First(&profile).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"}) + return + } + + c.JSON(http.StatusOK, h.toProfileResponse(&profile)) +} + +// Update handles PUT /api/admin/user-profiles/:id +func (h *AdminUserProfileHandler) Update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"}) + return + } + + var profile models.UserProfile + if err := h.db.First(&profile, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"}) + return + } + + var req UpdateUserProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Verified != nil { + profile.Verified = *req.Verified + } + if req.Bio != nil { + profile.Bio = *req.Bio + } + if req.PhoneNumber != nil { + profile.PhoneNumber = *req.PhoneNumber + } + if req.ProfilePicture != nil { + profile.ProfilePicture = *req.ProfilePicture + } + if req.DateOfBirth != nil { + if *req.DateOfBirth == "" { + profile.DateOfBirth = nil + } else { + dob, err := time.Parse("2006-01-02", *req.DateOfBirth) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for date_of_birth, use YYYY-MM-DD"}) + return + } + profile.DateOfBirth = &dob + } + } + + if err := h.db.Save(&profile).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user profile"}) + return + } + + h.db.Preload("User").First(&profile, id) + c.JSON(http.StatusOK, h.toProfileResponse(&profile)) +} + +// Delete handles DELETE /api/admin/user-profiles/:id +func (h *AdminUserProfileHandler) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"}) + return + } + + var profile models.UserProfile + if err := h.db.First(&profile, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"}) + return + } + + if err := h.db.Delete(&profile).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profile"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User profile deleted successfully"}) +} + +// BulkDelete handles DELETE /api/admin/user-profiles/bulk +func (h *AdminUserProfileHandler) 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 + } + + if err := h.db.Where("id IN ?", req.IDs).Delete(&models.UserProfile{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profiles"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User profiles deleted successfully", "count": len(req.IDs)}) +} + +// toProfileResponse converts a UserProfile model to UserProfileResponse +func (h *AdminUserProfileHandler) toProfileResponse(profile *models.UserProfile) UserProfileResponse { + response := UserProfileResponse{ + ID: profile.ID, + UserID: profile.UserID, + Verified: profile.Verified, + Bio: profile.Bio, + PhoneNumber: profile.PhoneNumber, + ProfilePicture: profile.ProfilePicture, + CreatedAt: profile.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: profile.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + if profile.DateOfBirth != nil { + dob := profile.DateOfBirth.Format("2006-01-02") + response.DateOfBirth = &dob + } + + if profile.User.ID != 0 { + response.Username = profile.User.Username + response.Email = profile.User.Email + } + + return response +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 4f774cd..2c21737 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -332,6 +332,54 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe notifPrefs.GET("/user/:user_id", notifPrefsHandler.GetByUser) } + // User profile management + userProfileHandler := handlers.NewAdminUserProfileHandler(db) + userProfiles := protected.Group("/user-profiles") + { + userProfiles.GET("", userProfileHandler.List) + userProfiles.DELETE("/bulk", userProfileHandler.BulkDelete) + userProfiles.GET("/:id", userProfileHandler.Get) + userProfiles.PUT("/:id", userProfileHandler.Update) + userProfiles.DELETE("/:id", userProfileHandler.Delete) + userProfiles.GET("/user/:user_id", userProfileHandler.GetByUser) + } + + // Apple social auth management + appleSocialAuthHandler := handlers.NewAdminAppleSocialAuthHandler(db) + appleSocialAuth := protected.Group("/apple-social-auth") + { + appleSocialAuth.GET("", appleSocialAuthHandler.List) + appleSocialAuth.DELETE("/bulk", appleSocialAuthHandler.BulkDelete) + appleSocialAuth.GET("/:id", appleSocialAuthHandler.Get) + appleSocialAuth.PUT("/:id", appleSocialAuthHandler.Update) + appleSocialAuth.DELETE("/:id", appleSocialAuthHandler.Delete) + appleSocialAuth.GET("/user/:user_id", appleSocialAuthHandler.GetByUser) + } + + // Task completion images management + completionImageHandler := handlers.NewAdminCompletionImageHandler(db) + completionImages := protected.Group("/completion-images") + { + completionImages.GET("", completionImageHandler.List) + completionImages.POST("", completionImageHandler.Create) + completionImages.DELETE("/bulk", completionImageHandler.BulkDelete) + completionImages.GET("/:id", completionImageHandler.Get) + completionImages.PUT("/:id", completionImageHandler.Update) + completionImages.DELETE("/:id", completionImageHandler.Delete) + } + + // Document images management + documentImageHandler := handlers.NewAdminDocumentImageHandler(db) + documentImages := protected.Group("/document-images") + { + documentImages.GET("", documentImageHandler.List) + documentImages.POST("", documentImageHandler.Create) + documentImages.DELETE("/bulk", documentImageHandler.BulkDelete) + documentImages.GET("/:id", documentImageHandler.Get) + documentImages.PUT("/:id", documentImageHandler.Update) + documentImages.DELETE("/:id", documentImageHandler.Delete) + } + // System settings management settingsHandler := handlers.NewAdminSettingsHandler(db) settings := protected.Group("/settings")