Add Next.js admin panel and implement background worker jobs

- Add full Next.js admin panel with:
  - User, residence, task, contractor, document management
  - Notifications and notification preferences management
  - Subscriptions and auth token management
  - Dashboard with stats
  - Lookup tables management (categories, priorities, statuses, etc.)
  - Admin user management

- Implement background worker job handlers:
  - HandleTaskReminder: sends push notifications for tasks due within 24h
  - HandleOverdueReminder: sends push notifications for overdue tasks
  - HandleDailyDigest: sends daily summary of pending tasks
  - HandleSendEmail: processes email sending jobs
  - HandleSendPush: processes push notification jobs

- Make worker job schedules configurable via environment variables:
  - TASK_REMINDER_HOUR, TASK_REMINDER_MINUTE (default: 20:00 UTC)
  - OVERDUE_REMINDER_HOUR (default: 09:00 UTC)
  - DAILY_DIGEST_HOUR (default: 11:00 UTC)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-27 23:35:00 -06:00
parent eed5c21586
commit 2817deee3c
129 changed files with 26838 additions and 693 deletions

View File

@@ -1,88 +0,0 @@
package admin
import (
"fmt"
_ "github.com/GoAdminGroup/go-admin/adapter/gin" // Gin adapter for GoAdmin
"github.com/GoAdminGroup/go-admin/engine"
"github.com/GoAdminGroup/go-admin/modules/config"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/modules/language"
"github.com/GoAdminGroup/go-admin/plugins/admin"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template"
"github.com/GoAdminGroup/go-admin/template/chartjs"
"github.com/GoAdminGroup/themes/adminlte"
"github.com/gin-gonic/gin"
appconfig "github.com/treytartt/mycrib-api/internal/config"
"github.com/treytartt/mycrib-api/internal/admin/tables"
)
// Setup initializes the GoAdmin panel
func Setup(r *gin.Engine, cfg *appconfig.Config) (*engine.Engine, error) {
eng := engine.Default()
// Register the AdminLTE theme
template.AddComp(chartjs.NewChart())
// Configure GoAdmin
adminConfig := config.Config{
Databases: config.DatabaseList{
"default": {
Host: cfg.Database.Host,
Port: fmt.Sprintf("%d", cfg.Database.Port),
User: cfg.Database.User,
Pwd: cfg.Database.Password,
Name: cfg.Database.Database,
MaxIdleConns: cfg.Database.MaxIdleConns,
MaxOpenConns: cfg.Database.MaxOpenConns,
Driver: db.DriverPostgresql,
},
},
UrlPrefix: "admin",
IndexUrl: "/",
Debug: cfg.Server.Debug,
Language: language.EN,
Theme: "adminlte",
Store: config.Store{
Path: "./uploads",
Prefix: "uploads",
},
Title: "MyCrib Admin",
Logo: "MyCrib",
MiniLogo: "MC",
BootstrapFilePath: "",
GoModFilePath: "",
ColorScheme: adminlte.ColorschemeSkinBlack,
Animation: config.PageAnimation{
Type: "fadeInUp",
},
}
// Add the admin plugin with generators
adminPlugin := admin.NewAdmin(GetTables())
// Initialize engine and add generators
if err := eng.AddConfig(&adminConfig).
AddGenerators(GetTables()).
AddPlugins(adminPlugin).
Use(r); err != nil {
return nil, err
}
// Add redirect for /admin to dashboard
r.GET("/admin", func(c *gin.Context) {
c.Redirect(302, "/admin/menu")
})
r.GET("/admin/", func(c *gin.Context) {
c.Redirect(302, "/admin/menu")
})
return eng, nil
}
// GetTables returns all table generators for the admin panel
func GetTables() table.GeneratorList {
return tables.Generators
}

View File

@@ -0,0 +1,280 @@
package dto
// PaginationParams holds pagination query parameters
type PaginationParams struct {
Page int `form:"page" binding:"omitempty,min=1"`
PerPage int `form:"per_page" binding:"omitempty,min=1,max=100"`
Search string `form:"search"`
SortBy string `form:"sort_by"`
SortDir string `form:"sort_dir" binding:"omitempty,oneof=asc desc"`
}
// GetPage returns the page number with default
func (p *PaginationParams) GetPage() int {
if p.Page < 1 {
return 1
}
return p.Page
}
// GetPerPage returns items per page with default
func (p *PaginationParams) GetPerPage() int {
if p.PerPage < 1 {
return 20
}
if p.PerPage > 100 {
return 100
}
return p.PerPage
}
// GetOffset calculates the database offset
func (p *PaginationParams) GetOffset() int {
return (p.GetPage() - 1) * p.GetPerPage()
}
// GetSortDir returns sort direction with default
func (p *PaginationParams) GetSortDir() string {
if p.SortDir == "asc" {
return "ASC"
}
return "DESC"
}
// UserFilters holds user-specific filter parameters
type UserFilters struct {
PaginationParams
IsActive *bool `form:"is_active"`
IsStaff *bool `form:"is_staff"`
IsSuperuser *bool `form:"is_superuser"`
Verified *bool `form:"verified"`
}
// CreateUserRequest for creating a new user
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=150"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name" binding:"max=150"`
LastName string `json:"last_name" binding:"max=150"`
PhoneNumber string `json:"phone_number" binding:"max=20"`
IsActive *bool `json:"is_active"`
IsStaff *bool `json:"is_staff"`
IsSuperuser *bool `json:"is_superuser"`
}
// UpdateUserRequest for updating a user
type UpdateUserRequest struct {
Username *string `json:"username" binding:"omitempty,min=3,max=150"`
Email *string `json:"email" binding:"omitempty,email"`
Password *string `json:"password" binding:"omitempty,min=8"`
FirstName *string `json:"first_name" binding:"omitempty,max=150"`
LastName *string `json:"last_name" binding:"omitempty,max=150"`
PhoneNumber *string `json:"phone_number" binding:"omitempty,max=20"`
IsActive *bool `json:"is_active"`
IsStaff *bool `json:"is_staff"`
IsSuperuser *bool `json:"is_superuser"`
}
// BulkDeleteRequest for bulk delete operations
type BulkDeleteRequest struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
// ResidenceFilters holds residence-specific filter parameters
type ResidenceFilters struct {
PaginationParams
IsActive *bool `form:"is_active"`
OwnerID *uint `form:"owner_id"`
}
// UpdateResidenceRequest for updating a residence
type UpdateResidenceRequest struct {
Name *string `json:"name" binding:"omitempty,max=200"`
StreetAddress *string `json:"street_address" binding:"omitempty,max=255"`
City *string `json:"city" binding:"omitempty,max=100"`
StateProvince *string `json:"state_province" binding:"omitempty,max=100"`
PostalCode *string `json:"postal_code" binding:"omitempty,max=20"`
Country *string `json:"country" binding:"omitempty,max=100"`
IsActive *bool `json:"is_active"`
IsPrimary *bool `json:"is_primary"`
}
// TaskFilters holds task-specific filter parameters
type TaskFilters struct {
PaginationParams
ResidenceID *uint `form:"residence_id"`
CategoryID *uint `form:"category_id"`
PriorityID *uint `form:"priority_id"`
StatusID *uint `form:"status_id"`
IsCancelled *bool `form:"is_cancelled"`
IsArchived *bool `form:"is_archived"`
}
// UpdateTaskRequest for updating a task
type UpdateTaskRequest struct {
Title *string `json:"title" binding:"omitempty,max=200"`
Description *string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
StatusID *uint `json:"status_id"`
IsCancelled *bool `json:"is_cancelled"`
IsArchived *bool `json:"is_archived"`
}
// ContractorFilters holds contractor-specific filter parameters
type ContractorFilters struct {
PaginationParams
IsActive *bool `form:"is_active"`
IsFavorite *bool `form:"is_favorite"`
ResidenceID *uint `form:"residence_id"`
}
// UpdateContractorRequest for updating a contractor
type UpdateContractorRequest struct {
Name *string `json:"name" binding:"omitempty,max=200"`
Company *string `json:"company" binding:"omitempty,max=200"`
Phone *string `json:"phone" binding:"omitempty,max=20"`
Email *string `json:"email" binding:"omitempty,email"`
Website *string `json:"website" binding:"omitempty,max=200"`
Notes *string `json:"notes"`
IsFavorite *bool `json:"is_favorite"`
IsActive *bool `json:"is_active"`
}
// DocumentFilters holds document-specific filter parameters
type DocumentFilters struct {
PaginationParams
IsActive *bool `form:"is_active"`
ResidenceID *uint `form:"residence_id"`
DocumentType *string `form:"document_type"`
}
// UpdateDocumentRequest for updating a document
type UpdateDocumentRequest struct {
Title *string `json:"title" binding:"omitempty,max=200"`
Description *string `json:"description"`
Vendor *string `json:"vendor" binding:"omitempty,max=200"`
SerialNumber *string `json:"serial_number" binding:"omitempty,max=100"`
ModelNumber *string `json:"model_number" binding:"omitempty,max=100"`
IsActive *bool `json:"is_active"`
}
// NotificationFilters holds notification-specific filter parameters
type NotificationFilters struct {
PaginationParams
UserID *uint `form:"user_id"`
NotificationType *string `form:"notification_type"`
Sent *bool `form:"sent"`
Read *bool `form:"read"`
}
// UpdateNotificationRequest for updating a notification
type UpdateNotificationRequest struct {
Title *string `json:"title" binding:"omitempty,max=200"`
Body *string `json:"body" binding:"omitempty,max=1000"`
Read *bool `json:"read"`
}
// SubscriptionFilters holds subscription-specific filter parameters
type SubscriptionFilters struct {
PaginationParams
UserID *uint `form:"user_id"`
Tier *string `form:"tier"`
Platform *string `form:"platform"`
AutoRenew *bool `form:"auto_renew"`
Active *bool `form:"active"`
}
// UpdateSubscriptionRequest for updating a subscription
type UpdateSubscriptionRequest struct {
Tier *string `json:"tier" binding:"omitempty,oneof=free premium pro"`
AutoRenew *bool `json:"auto_renew"`
}
// CreateResidenceRequest for creating a new residence
type CreateResidenceRequest struct {
OwnerID uint `json:"owner_id" binding:"required"`
Name string `json:"name" binding:"required,max=200"`
PropertyTypeID *uint `json:"property_type_id"`
StreetAddress string `json:"street_address" binding:"max=255"`
ApartmentUnit string `json:"apartment_unit" binding:"max=50"`
City string `json:"city" binding:"max=100"`
StateProvince string `json:"state_province" binding:"max=100"`
PostalCode string `json:"postal_code" binding:"max=20"`
Country string `json:"country" binding:"max=100"`
Bedrooms *int `json:"bedrooms"`
Bathrooms *float64 `json:"bathrooms"`
SquareFootage *int `json:"square_footage"`
YearBuilt *int `json:"year_built"`
Description string `json:"description"`
IsPrimary bool `json:"is_primary"`
}
// CreateTaskRequest for creating a new task
type CreateTaskRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
StatusID *uint `json:"status_id"`
FrequencyID *uint `json:"frequency_id"`
AssignedToID *uint `json:"assigned_to_id"`
DueDate *string `json:"due_date"`
EstimatedCost *float64 `json:"estimated_cost"`
ContractorID *uint `json:"contractor_id"`
}
// CreateContractorRequest for creating a new contractor
type CreateContractorRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Name string `json:"name" binding:"required,max=200"`
Company string `json:"company" binding:"max=200"`
Phone string `json:"phone" binding:"max=20"`
Email string `json:"email" binding:"omitempty,email"`
Website string `json:"website" binding:"max=200"`
Notes string `json:"notes"`
StreetAddress string `json:"street_address" binding:"max=255"`
City string `json:"city" binding:"max=100"`
StateProvince string `json:"state_province" binding:"max=100"`
PostalCode string `json:"postal_code" binding:"max=20"`
IsFavorite bool `json:"is_favorite"`
SpecialtyIDs []uint `json:"specialty_ids"`
}
// CreateDocumentRequest for creating a new document
type CreateDocumentRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Description string `json:"description"`
DocumentType string `json:"document_type" binding:"omitempty,oneof=general warranty receipt contract insurance manual"`
FileURL string `json:"file_url" binding:"max=500"`
FileName string `json:"file_name" binding:"max=255"`
FileSize *int64 `json:"file_size"`
MimeType string `json:"mime_type" binding:"max=100"`
PurchaseDate *string `json:"purchase_date"`
ExpiryDate *string `json:"expiry_date"`
PurchasePrice *float64 `json:"purchase_price"`
Vendor string `json:"vendor" binding:"max=200"`
SerialNumber string `json:"serial_number" binding:"max=100"`
ModelNumber string `json:"model_number" binding:"max=100"`
TaskID *uint `json:"task_id"`
}
// SendTestNotificationRequest for sending a test push notification
type SendTestNotificationRequest struct {
UserID uint `json:"user_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Body string `json:"body" binding:"required,max=500"`
}
// SendTestEmailRequest for sending a test email
type SendTestEmailRequest struct {
UserID uint `json:"user_id" binding:"required"`
Subject string `json:"subject" binding:"required,max=200"`
Body string `json:"body" binding:"required"`
}

View File

@@ -0,0 +1,219 @@
package dto
// PaginatedResponse represents a paginated API response
type PaginatedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
// NewPaginatedResponse creates a new paginated response
func NewPaginatedResponse(data interface{}, total int64, page, perPage int) PaginatedResponse {
totalPages := int(total) / perPage
if int(total)%perPage > 0 {
totalPages++
}
return PaginatedResponse{
Data: data,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
}
// UserResponse represents a user in admin responses
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsActive bool `json:"is_active"`
IsStaff bool `json:"is_staff"`
IsSuperuser bool `json:"is_superuser"`
DateJoined string `json:"date_joined"`
LastLogin *string `json:"last_login,omitempty"`
// Profile info
Verified bool `json:"verified"`
PhoneNumber *string `json:"phone_number,omitempty"`
// Counts
ResidenceCount int `json:"residence_count"`
TaskCount int `json:"task_count"`
}
// UserDetailResponse includes more details for single user view
type UserDetailResponse struct {
UserResponse
Residences []ResidenceSummary `json:"residences,omitempty"`
Devices []DeviceSummary `json:"devices,omitempty"`
}
// ResidenceSummary is a brief residence representation
type ResidenceSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
IsOwner bool `json:"is_owner"`
IsActive bool `json:"is_active"`
}
// DeviceSummary is a brief device representation
type DeviceSummary struct {
ID uint `json:"id"`
Type string `json:"type"` // "apns" or "gcm"
Name string `json:"name"`
IsActive bool `json:"is_active"`
}
// UserSummary is a brief user representation
type UserSummary struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}
// ResidenceResponse represents a residence in admin responses
type ResidenceResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
OwnerID uint `json:"owner_id"`
OwnerName string `json:"owner_name"`
PropertyType *string `json:"property_type,omitempty"`
StreetAddress string `json:"street_address"`
City string `json:"city"`
StateProvince string `json:"state_province"`
PostalCode string `json:"postal_code"`
Country string `json:"country"`
Bedrooms *int `json:"bedrooms,omitempty"`
Bathrooms *float64 `json:"bathrooms,omitempty"`
SquareFootage *int `json:"square_footage,omitempty"`
IsPrimary bool `json:"is_primary"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
}
// ResidenceDetailResponse includes more details for single residence view
type ResidenceDetailResponse struct {
ResidenceResponse
Owner *UserSummary `json:"owner,omitempty"`
SharedUsers []UserSummary `json:"shared_users,omitempty"`
TaskCount int `json:"task_count"`
DocumentCount int `json:"document_count"`
}
// TaskResponse represents a task in admin responses
type TaskResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"`
Title string `json:"title"`
Description string `json:"description"`
CreatedByName string `json:"created_by_name"`
CategoryName *string `json:"category_name,omitempty"`
PriorityName *string `json:"priority_name,omitempty"`
StatusName *string `json:"status_name,omitempty"`
DueDate *string `json:"due_date,omitempty"`
EstimatedCost *float64 `json:"estimated_cost,omitempty"`
IsCancelled bool `json:"is_cancelled"`
IsArchived bool `json:"is_archived"`
CreatedAt string `json:"created_at"`
}
// TaskDetailResponse includes more details for single task view
type TaskDetailResponse struct {
TaskResponse
AssignedTo *UserSummary `json:"assigned_to,omitempty"`
CompletionCount int `json:"completion_count"`
}
// ContractorResponse represents a contractor in admin responses
type ContractorResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"`
Name string `json:"name"`
Company string `json:"company"`
Phone string `json:"phone"`
Email string `json:"email"`
Website string `json:"website"`
City string `json:"city"`
Rating *float64 `json:"rating,omitempty"`
Specialties []string `json:"specialties,omitempty"`
IsFavorite bool `json:"is_favorite"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
}
// ContractorDetailResponse includes more details for single contractor view
type ContractorDetailResponse struct {
ContractorResponse
TaskCount int `json:"task_count"`
}
// DocumentResponse represents a document in admin responses
type DocumentResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"`
Title string `json:"title"`
Description string `json:"description"`
DocumentType string `json:"document_type"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
Vendor string `json:"vendor"`
ExpiryDate *string `json:"expiry_date,omitempty"`
PurchaseDate *string `json:"purchase_date,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
}
// DocumentDetailResponse includes more details for single document view
type DocumentDetailResponse struct {
DocumentResponse
TaskTitle *string `json:"task_title,omitempty"`
}
// NotificationResponse represents a notification in admin responses
type NotificationResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
UserEmail string `json:"user_email"`
Username string `json:"username"`
NotificationType string `json:"notification_type"`
Title string `json:"title"`
Body string `json:"body"`
Sent bool `json:"sent"`
SentAt *string `json:"sent_at,omitempty"`
Read bool `json:"read"`
ReadAt *string `json:"read_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// NotificationDetailResponse includes more details for single notification view
type NotificationDetailResponse struct {
NotificationResponse
Data string `json:"data"`
TaskID *uint `json:"task_id,omitempty"`
}
// SubscriptionResponse represents a subscription in admin responses
type SubscriptionResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
UserEmail string `json:"user_email"`
Username string `json:"username"`
Tier string `json:"tier"`
Platform string `json:"platform"`
AutoRenew bool `json:"auto_renew"`
SubscribedAt *string `json:"subscribed_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
CancelledAt *string `json:"cancelled_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// SubscriptionDetailResponse includes more details for single subscription view
type SubscriptionDetailResponse struct {
SubscriptionResponse
}

View File

@@ -0,0 +1,311 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/middleware"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminUserManagementHandler handles admin user management endpoints
type AdminUserManagementHandler struct {
db *gorm.DB
}
// NewAdminUserManagementHandler creates a new admin user management handler
func NewAdminUserManagementHandler(db *gorm.DB) *AdminUserManagementHandler {
return &AdminUserManagementHandler{db: db}
}
// Note: AdminUserResponse is defined in auth_handler.go and reused here
// AdminUserFilters for listing admin users
type AdminUserFilters struct {
dto.PaginationParams
Role *string `form:"role"`
IsActive *bool `form:"is_active"`
}
// CreateAdminUserRequest for creating a new admin user
type CreateAdminUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name" binding:"max=100"`
LastName string `json:"last_name" binding:"max=100"`
Role string `json:"role" binding:"omitempty,oneof=admin super_admin"`
IsActive *bool `json:"is_active"`
}
// UpdateAdminUserRequest for updating an admin user
type UpdateAdminUserRequest struct {
Email *string `json:"email" binding:"omitempty,email"`
Password *string `json:"password" binding:"omitempty,min=8"`
FirstName *string `json:"first_name" binding:"omitempty,max=100"`
LastName *string `json:"last_name" binding:"omitempty,max=100"`
Role *string `json:"role" binding:"omitempty,oneof=admin super_admin"`
IsActive *bool `json:"is_active"`
}
// List handles GET /api/admin/admin-users
func (h *AdminUserManagementHandler) List(c *gin.Context) {
var filters AdminUserFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var adminUsers []models.AdminUser
var total int64
query := h.db.Model(&models.AdminUser{})
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where("email ILIKE ? OR first_name ILIKE ? OR last_name ILIKE ?", search, search, search)
}
// Apply filters
if filters.Role != nil {
query = query.Where("role = ?", *filters.Role)
}
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
// 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(&adminUsers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin users"})
return
}
responses := make([]AdminUserResponse, len(adminUsers))
for i, u := range adminUsers {
responses[i] = h.toAdminUserResponse(&u)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/admin-users/:id
func (h *AdminUserManagementHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin user ID"})
return
}
var adminUser models.AdminUser
if err := h.db.First(&adminUser, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Admin user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin user"})
return
}
c.JSON(http.StatusOK, h.toAdminUserResponse(&adminUser))
}
// Create handles POST /api/admin/admin-users
func (h *AdminUserManagementHandler) Create(c *gin.Context) {
// Only super admins can create admin users
currentAdmin, exists := c.Get(middleware.AdminUserKey)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
admin := currentAdmin.(*models.AdminUser)
if !admin.IsSuperAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Only super admins can create admin users"})
return
}
var req CreateAdminUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if email already exists
var existingCount int64
h.db.Model(&models.AdminUser{}).Where("email = ?", req.Email).Count(&existingCount)
if existingCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
}
adminUser := models.AdminUser{
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
Role: models.AdminRole(req.Role),
IsActive: true,
}
if req.Role == "" {
adminUser.Role = models.AdminRoleAdmin
}
if req.IsActive != nil {
adminUser.IsActive = *req.IsActive
}
if err := adminUser.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
if err := h.db.Create(&adminUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin user"})
return
}
c.JSON(http.StatusCreated, h.toAdminUserResponse(&adminUser))
}
// Update handles PUT /api/admin/admin-users/:id
func (h *AdminUserManagementHandler) Update(c *gin.Context) {
// Only super admins can update admin users
currentAdmin, exists := c.Get(middleware.AdminUserKey)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
admin := currentAdmin.(*models.AdminUser)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin user ID"})
return
}
// Allow users to update themselves, but only super admins can update others
if uint(id) != admin.ID && !admin.IsSuperAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Only super admins can update other admin users"})
return
}
var adminUser models.AdminUser
if err := h.db.First(&adminUser, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Admin user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin user"})
return
}
var req UpdateAdminUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Email != nil {
// Check if email already exists for another user
var existingCount int64
h.db.Model(&models.AdminUser{}).Where("email = ? AND id != ?", *req.Email, id).Count(&existingCount)
if existingCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
}
adminUser.Email = *req.Email
}
if req.FirstName != nil {
adminUser.FirstName = *req.FirstName
}
if req.LastName != nil {
adminUser.LastName = *req.LastName
}
// Only super admins can change roles
if req.Role != nil && admin.IsSuperAdmin() {
adminUser.Role = models.AdminRole(*req.Role)
}
if req.IsActive != nil && admin.IsSuperAdmin() {
// Prevent disabling yourself
if uint(id) == admin.ID && !*req.IsActive {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot deactivate your own account"})
return
}
adminUser.IsActive = *req.IsActive
}
if req.Password != nil {
if err := adminUser.SetPassword(*req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
}
if err := h.db.Save(&adminUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update admin user"})
return
}
c.JSON(http.StatusOK, h.toAdminUserResponse(&adminUser))
}
// Delete handles DELETE /api/admin/admin-users/:id
func (h *AdminUserManagementHandler) Delete(c *gin.Context) {
// Only super admins can delete admin users
currentAdmin, exists := c.Get(middleware.AdminUserKey)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
admin := currentAdmin.(*models.AdminUser)
if !admin.IsSuperAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Only super admins can delete admin users"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin user ID"})
return
}
// Prevent self-deletion
if uint(id) == admin.ID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete your own account"})
return
}
var adminUser models.AdminUser
if err := h.db.First(&adminUser, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Admin user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin user"})
return
}
if err := h.db.Delete(&adminUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete admin user"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Admin user deleted successfully"})
}
func (h *AdminUserManagementHandler) toAdminUserResponse(u *models.AdminUser) AdminUserResponse {
return NewAdminUserResponse(u)
}

View File

@@ -0,0 +1,140 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/treytartt/mycrib-api/internal/config"
"github.com/treytartt/mycrib-api/internal/middleware"
"github.com/treytartt/mycrib-api/internal/models"
"github.com/treytartt/mycrib-api/internal/repositories"
)
// AdminAuthHandler handles admin authentication endpoints
type AdminAuthHandler struct {
adminRepo *repositories.AdminRepository
cfg *config.Config
}
// NewAdminAuthHandler creates a new admin auth handler
func NewAdminAuthHandler(adminRepo *repositories.AdminRepository, cfg *config.Config) *AdminAuthHandler {
return &AdminAuthHandler{
adminRepo: adminRepo,
cfg: cfg,
}
}
// LoginRequest represents the admin login request
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
// LoginResponse represents the admin login response
type LoginResponse struct {
Token string `json:"token"`
Admin AdminUserResponse `json:"admin"`
}
// AdminUserResponse represents an admin user in API responses
type AdminUserResponse struct {
ID uint `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role models.AdminRole `json:"role"`
IsActive bool `json:"is_active"`
LastLogin *string `json:"last_login,omitempty"`
CreatedAt string `json:"created_at"`
}
// NewAdminUserResponse creates an AdminUserResponse from an AdminUser model
func NewAdminUserResponse(admin *models.AdminUser) AdminUserResponse {
resp := AdminUserResponse{
ID: admin.ID,
Email: admin.Email,
FirstName: admin.FirstName,
LastName: admin.LastName,
Role: admin.Role,
IsActive: admin.IsActive,
CreatedAt: admin.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if admin.LastLogin != nil {
lastLogin := admin.LastLogin.Format("2006-01-02T15:04:05Z")
resp.LastLogin = &lastLogin
}
return resp
}
// Login handles POST /api/admin/auth/login
func (h *AdminAuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Find admin by email
admin, err := h.adminRepo.FindByEmail(req.Email)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
}
// Check password
if !admin.CheckPassword(req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
}
// Check if admin is active
if !admin.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Account is disabled"})
return
}
// Generate JWT token
token, err := middleware.GenerateAdminToken(admin, h.cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Update last login
_ = h.adminRepo.UpdateLastLogin(admin.ID)
// Refresh admin data after updating last login
admin, _ = h.adminRepo.FindByID(admin.ID)
c.JSON(http.StatusOK, LoginResponse{
Token: token,
Admin: NewAdminUserResponse(admin),
})
}
// Logout handles POST /api/admin/auth/logout
// Note: JWT tokens are stateless, so logout is handled client-side by removing the token
func (h *AdminAuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
// Me handles GET /api/admin/auth/me
func (h *AdminAuthHandler) Me(c *gin.Context) {
admin := c.MustGet(middleware.AdminUserKey).(*models.AdminUser)
c.JSON(http.StatusOK, NewAdminUserResponse(admin))
}
// RefreshToken handles POST /api/admin/auth/refresh
func (h *AdminAuthHandler) RefreshToken(c *gin.Context) {
admin := c.MustGet(middleware.AdminUserKey).(*models.AdminUser)
// Generate new token
token, err := middleware.GenerateAdminToken(admin, h.cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}

View File

@@ -0,0 +1,155 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminAuthTokenHandler handles admin auth token management endpoints
type AdminAuthTokenHandler struct {
db *gorm.DB
}
// NewAdminAuthTokenHandler creates a new admin auth token handler
func NewAdminAuthTokenHandler(db *gorm.DB) *AdminAuthTokenHandler {
return &AdminAuthTokenHandler{db: db}
}
// AuthTokenResponse represents an auth token in API responses
type AuthTokenResponse struct {
Key string `json:"key"`
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Created string `json:"created"`
}
// List handles GET /api/admin/auth-tokens
func (h *AdminAuthTokenHandler) 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 tokens []models.AuthToken
var total int64
query := h.db.Model(&models.AuthToken{}).Preload("User")
// Apply search (search by user info)
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN auth_user ON auth_user.id = user_authtoken.user_id").
Where(
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_authtoken.key ILIKE ?",
search, search, search,
)
}
// Get total count
query.Count(&total)
// Apply sorting
sortBy := "created"
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(&tokens).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch auth tokens"})
return
}
// Build response
responses := make([]AuthTokenResponse, len(tokens))
for i, token := range tokens {
responses[i] = AuthTokenResponse{
Key: token.Key,
UserID: token.UserID,
Username: token.User.Username,
Email: token.User.Email,
Created: token.Created.Format("2006-01-02T15:04:05Z"),
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/auth-tokens/:id (id is actually user_id)
func (h *AdminAuthTokenHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var token models.AuthToken
if err := h.db.Preload("User").Where("user_id = ?", id).First(&token).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Auth token not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch auth token"})
return
}
response := AuthTokenResponse{
Key: token.Key,
UserID: token.UserID,
Username: token.User.Username,
Email: token.User.Email,
Created: token.Created.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/auth-tokens/:id (revoke token)
func (h *AdminAuthTokenHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
result := h.db.Where("user_id = ?", id).Delete(&models.AuthToken{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Auth token not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Auth token revoked successfully"})
}
// BulkDelete handles DELETE /api/admin/auth-tokens/bulk
func (h *AdminAuthTokenHandler) 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("user_id IN ?", req.IDs).Delete(&models.AuthToken{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke tokens"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Auth tokens revoked successfully", "count": result.RowsAffected})
}

View File

@@ -0,0 +1,266 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminCompletionHandler handles admin task completion management endpoints
type AdminCompletionHandler struct {
db *gorm.DB
}
// NewAdminCompletionHandler creates a new admin completion handler
func NewAdminCompletionHandler(db *gorm.DB) *AdminCompletionHandler {
return &AdminCompletionHandler{db: db}
}
// CompletionResponse represents a task completion in API responses
type CompletionResponse struct {
ID uint `json:"id"`
TaskID uint `json:"task_id"`
TaskTitle string `json:"task_title"`
ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"`
CompletedByID uint `json:"completed_by_id"`
CompletedBy string `json:"completed_by"`
CompletedAt string `json:"completed_at"`
Notes string `json:"notes"`
ActualCost *string `json:"actual_cost"`
PhotoURL string `json:"photo_url"`
CreatedAt string `json:"created_at"`
}
// CompletionFilters extends PaginationParams with completion-specific filters
type CompletionFilters struct {
dto.PaginationParams
TaskID *uint `form:"task_id"`
ResidenceID *uint `form:"residence_id"`
UserID *uint `form:"user_id"`
}
// List handles GET /api/admin/completions
func (h *AdminCompletionHandler) List(c *gin.Context) {
var filters CompletionFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var completions []models.TaskCompletion
var total int64
query := h.db.Model(&models.TaskCompletion{}).
Preload("Task").
Preload("Task.Residence").
Preload("CompletedBy")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
Joins("JOIN auth_user ON auth_user.id = task_taskcompletion.completed_by_id").
Where(
"task_task.title ILIKE ? OR auth_user.username ILIKE ? OR task_taskcompletion.notes ILIKE ?",
search, search, search,
)
}
// Apply filters
if filters.TaskID != nil {
query = query.Where("task_id = ?", *filters.TaskID)
}
if filters.ResidenceID != nil {
query = query.Joins("JOIN task_task t ON t.id = task_taskcompletion.task_id").
Where("t.residence_id = ?", *filters.ResidenceID)
}
if filters.UserID != nil {
query = query.Where("completed_by_id = ?", *filters.UserID)
}
// Get total count
query.Count(&total)
// Apply sorting
sortBy := "completed_at"
if filters.SortBy != "" {
sortBy = filters.SortBy
}
sortDir := "DESC"
if filters.SortDir != "" {
sortDir = filters.GetSortDir()
}
query = query.Order(sortBy + " " + sortDir)
// Apply pagination
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&completions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completions"})
return
}
// Build response
responses := make([]CompletionResponse, len(completions))
for i, completion := range completions {
responses[i] = h.toCompletionResponse(&completion)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/completions/:id
func (h *AdminCompletionHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"})
return
}
var completion models.TaskCompletion
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion"})
return
}
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
}
// Delete handles DELETE /api/admin/completions/:id
func (h *AdminCompletionHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"})
return
}
var completion models.TaskCompletion
if err := h.db.First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion"})
return
}
if err := h.db.Delete(&completion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Completion deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/completions/bulk
func (h *AdminCompletionHandler) 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.TaskCompletion{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completions"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Completions deleted successfully", "count": result.RowsAffected})
}
// UpdateCompletionRequest represents the request to update a completion
type UpdateCompletionRequest struct {
Notes *string `json:"notes"`
ActualCost *string `json:"actual_cost"`
}
// Update handles PUT /api/admin/completions/:id
func (h *AdminCompletionHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"})
return
}
var completion models.TaskCompletion
if err := h.db.First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion"})
return
}
var req UpdateCompletionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Notes != nil {
completion.Notes = *req.Notes
}
if req.ActualCost != nil {
if *req.ActualCost == "" {
completion.ActualCost = nil
} else {
cost, err := decimal.NewFromString(*req.ActualCost)
if err == nil {
completion.ActualCost = &cost
}
}
}
if err := h.db.Save(&completion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update completion"})
return
}
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id)
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
}
// toCompletionResponse converts a TaskCompletion model to CompletionResponse
func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCompletion) CompletionResponse {
response := CompletionResponse{
ID: completion.ID,
TaskID: completion.TaskID,
CompletedByID: completion.CompletedByID,
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
Notes: completion.Notes,
PhotoURL: completion.PhotoURL,
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if completion.Task.ID != 0 {
response.TaskTitle = completion.Task.Title
response.ResidenceID = completion.Task.ResidenceID
if completion.Task.Residence.ID != 0 {
response.ResidenceName = completion.Task.Residence.Name
}
}
if completion.CompletedBy.ID != 0 {
response.CompletedBy = completion.CompletedBy.Username
}
if completion.ActualCost != nil {
cost := completion.ActualCost.String()
response.ActualCost = &cost
}
return response
}

View File

@@ -0,0 +1,307 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminContractorHandler handles admin contractor management endpoints
type AdminContractorHandler struct {
db *gorm.DB
}
// NewAdminContractorHandler creates a new admin contractor handler
func NewAdminContractorHandler(db *gorm.DB) *AdminContractorHandler {
return &AdminContractorHandler{db: db}
}
// List handles GET /api/admin/contractors
func (h *AdminContractorHandler) List(c *gin.Context) {
var filters dto.ContractorFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var contractors []models.Contractor
var total int64
query := h.db.Model(&models.Contractor{}).
Preload("Residence").
Preload("CreatedBy").
Preload("Specialties")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where(
"name ILIKE ? OR company ILIKE ? OR email ILIKE ? OR phone ILIKE ?",
search, search, search, search,
)
}
// Apply filters
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.IsFavorite != nil {
query = query.Where("is_favorite = ?", *filters.IsFavorite)
}
if filters.ResidenceID != nil {
query = query.Where("residence_id = ?", *filters.ResidenceID)
}
// 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(&contractors).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractors"})
return
}
// Build response
responses := make([]dto.ContractorResponse, len(contractors))
for i, contractor := range contractors {
responses[i] = h.toContractorResponse(&contractor)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/contractors/:id
func (h *AdminContractorHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"})
return
}
var contractor models.Contractor
if err := h.db.
Preload("Residence").
Preload("CreatedBy").
Preload("Specialties").
First(&contractor, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contractor not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor"})
return
}
response := dto.ContractorDetailResponse{
ContractorResponse: h.toContractorResponse(&contractor),
}
// Get task count
var taskCount int64
h.db.Model(&models.Task{}).Where("contractor_id = ?", contractor.ID).Count(&taskCount)
response.TaskCount = int(taskCount)
c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/contractors/:id
func (h *AdminContractorHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"})
return
}
var contractor models.Contractor
if err := h.db.First(&contractor, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contractor not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor"})
return
}
var req dto.UpdateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
contractor.Name = *req.Name
}
if req.Company != nil {
contractor.Company = *req.Company
}
if req.Phone != nil {
contractor.Phone = *req.Phone
}
if req.Email != nil {
contractor.Email = *req.Email
}
if req.Website != nil {
contractor.Website = *req.Website
}
if req.Notes != nil {
contractor.Notes = *req.Notes
}
if req.IsFavorite != nil {
contractor.IsFavorite = *req.IsFavorite
}
if req.IsActive != nil {
contractor.IsActive = *req.IsActive
}
if err := h.db.Save(&contractor).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update contractor"})
return
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Specialties").First(&contractor, id)
c.JSON(http.StatusOK, h.toContractorResponse(&contractor))
}
// Create handles POST /api/admin/contractors
func (h *AdminContractorHandler) Create(c *gin.Context) {
var req dto.CreateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify residence exists
var residence models.Residence
if err := h.db.First(&residence, req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
}
// Verify created_by user exists
var creator models.User
if err := h.db.First(&creator, req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Creator user not found"})
return
}
contractor := models.Contractor{
ResidenceID: req.ResidenceID,
CreatedByID: req.CreatedByID,
Name: req.Name,
Company: req.Company,
Phone: req.Phone,
Email: req.Email,
Website: req.Website,
Notes: req.Notes,
StreetAddress: req.StreetAddress,
City: req.City,
StateProvince: req.StateProvince,
PostalCode: req.PostalCode,
IsFavorite: req.IsFavorite,
IsActive: true,
}
if err := h.db.Create(&contractor).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create contractor"})
return
}
// Add specialties if provided
if len(req.SpecialtyIDs) > 0 {
var specialties []models.ContractorSpecialty
h.db.Where("id IN ?", req.SpecialtyIDs).Find(&specialties)
h.db.Model(&contractor).Association("Specialties").Append(specialties)
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Specialties").First(&contractor, contractor.ID)
c.JSON(http.StatusCreated, h.toContractorResponse(&contractor))
}
// Delete handles DELETE /api/admin/contractors/:id
func (h *AdminContractorHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"})
return
}
var contractor models.Contractor
if err := h.db.First(&contractor, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contractor not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor"})
return
}
// Soft delete
contractor.IsActive = false
if err := h.db.Save(&contractor).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contractor"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Contractor deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/contractors/bulk
func (h *AdminContractorHandler) 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
}
// Soft delete - deactivate all
if err := h.db.Model(&models.Contractor{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contractors"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Contractors deactivated successfully", "count": len(req.IDs)})
}
func (h *AdminContractorHandler) toContractorResponse(contractor *models.Contractor) dto.ContractorResponse {
response := dto.ContractorResponse{
ID: contractor.ID,
ResidenceID: contractor.ResidenceID,
Name: contractor.Name,
Company: contractor.Company,
Phone: contractor.Phone,
Email: contractor.Email,
Website: contractor.Website,
City: contractor.City,
IsFavorite: contractor.IsFavorite,
IsActive: contractor.IsActive,
CreatedAt: contractor.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if contractor.Residence.ID != 0 {
response.ResidenceName = contractor.Residence.Name
}
if contractor.Rating != nil {
response.Rating = contractor.Rating
}
// Add specialties
for _, s := range contractor.Specialties {
response.Specialties = append(response.Specialties, s.Name)
}
return response
}

View File

@@ -0,0 +1,142 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminDashboardHandler handles admin dashboard endpoints
type AdminDashboardHandler struct {
db *gorm.DB
}
// NewAdminDashboardHandler creates a new admin dashboard handler
func NewAdminDashboardHandler(db *gorm.DB) *AdminDashboardHandler {
return &AdminDashboardHandler{db: db}
}
// DashboardStats holds all dashboard statistics
type DashboardStats struct {
Users UserStats `json:"users"`
Residences ResidenceStats `json:"residences"`
Tasks TaskStats `json:"tasks"`
Contractors ContractorStats `json:"contractors"`
Documents DocumentStats `json:"documents"`
Notifications NotificationStats `json:"notifications"`
Subscriptions SubscriptionStats `json:"subscriptions"`
}
// UserStats holds user-related statistics
type UserStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Verified int64 `json:"verified"`
New30d int64 `json:"new_30d"`
}
// ResidenceStats holds residence-related statistics
type ResidenceStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
New30d int64 `json:"new_30d"`
}
// TaskStats holds task-related statistics
type TaskStats struct {
Total int64 `json:"total"`
Pending int64 `json:"pending"`
Completed int64 `json:"completed"`
Overdue int64 `json:"overdue"`
}
// ContractorStats holds contractor-related statistics
type ContractorStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Favorite int64 `json:"favorite"`
}
// DocumentStats holds document-related statistics
type DocumentStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Expired int64 `json:"expired"`
}
// NotificationStats holds notification-related statistics
type NotificationStats struct {
Total int64 `json:"total"`
Sent int64 `json:"sent"`
Pending int64 `json:"pending"`
Read int64 `json:"read"`
}
// SubscriptionStats holds subscription-related statistics
type SubscriptionStats struct {
Total int64 `json:"total"`
Free int64 `json:"free"`
Premium int64 `json:"premium"`
Pro int64 `json:"pro"`
}
// GetStats handles GET /api/admin/dashboard/stats
func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
stats := DashboardStats{}
now := time.Now()
thirtyDaysAgo := now.AddDate(0, 0, -30)
// User stats
h.db.Model(&models.User{}).Count(&stats.Users.Total)
h.db.Model(&models.User{}).Where("is_active = ?", true).Count(&stats.Users.Active)
h.db.Model(&models.User{}).Where("verified = ?", true).Count(&stats.Users.Verified)
h.db.Model(&models.User{}).Where("date_joined >= ?", thirtyDaysAgo).Count(&stats.Users.New30d)
// Residence stats
h.db.Model(&models.Residence{}).Count(&stats.Residences.Total)
h.db.Model(&models.Residence{}).Where("is_active = ?", true).Count(&stats.Residences.Active)
h.db.Model(&models.Residence{}).Where("created_at >= ?", thirtyDaysAgo).Count(&stats.Residences.New30d)
// Task stats
h.db.Model(&models.Task{}).Count(&stats.Tasks.Total)
h.db.Model(&models.Task{}).Where("is_cancelled = ? AND is_archived = ?", false, false).
Joins("JOIN task_taskstatus ON task_taskstatus.id = tasks.status_id").
Where("task_taskstatus.name IN ?", []string{"pending", "in_progress"}).
Count(&stats.Tasks.Pending)
h.db.Model(&models.Task{}).
Joins("JOIN task_taskstatus ON task_taskstatus.id = tasks.status_id").
Where("task_taskstatus.name = ?", "completed").
Count(&stats.Tasks.Completed)
h.db.Model(&models.Task{}).Where("due_date < ? AND is_cancelled = ? AND is_archived = ?", now, false, false).
Joins("JOIN task_taskstatus ON task_taskstatus.id = tasks.status_id").
Where("task_taskstatus.name NOT IN ?", []string{"completed", "cancelled"}).
Count(&stats.Tasks.Overdue)
// Contractor stats
h.db.Model(&models.Contractor{}).Count(&stats.Contractors.Total)
h.db.Model(&models.Contractor{}).Where("is_active = ?", true).Count(&stats.Contractors.Active)
h.db.Model(&models.Contractor{}).Where("is_favorite = ?", true).Count(&stats.Contractors.Favorite)
// Document stats
h.db.Model(&models.Document{}).Count(&stats.Documents.Total)
h.db.Model(&models.Document{}).Where("is_active = ?", true).Count(&stats.Documents.Active)
h.db.Model(&models.Document{}).Where("expiry_date < ? AND is_active = ?", now, true).Count(&stats.Documents.Expired)
// Notification stats
h.db.Model(&models.Notification{}).Count(&stats.Notifications.Total)
h.db.Model(&models.Notification{}).Where("sent = ?", true).Count(&stats.Notifications.Sent)
h.db.Model(&models.Notification{}).Where("sent = ?", false).Count(&stats.Notifications.Pending)
h.db.Model(&models.Notification{}).Where("read = ?", true).Count(&stats.Notifications.Read)
// Subscription stats
h.db.Model(&models.UserSubscription{}).Count(&stats.Subscriptions.Total)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "free").Count(&stats.Subscriptions.Free)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "premium").Count(&stats.Subscriptions.Premium)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "pro").Count(&stats.Subscriptions.Pro)
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,315 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminDocumentHandler handles admin document management endpoints
type AdminDocumentHandler struct {
db *gorm.DB
}
// NewAdminDocumentHandler creates a new admin document handler
func NewAdminDocumentHandler(db *gorm.DB) *AdminDocumentHandler {
return &AdminDocumentHandler{db: db}
}
// List handles GET /api/admin/documents
func (h *AdminDocumentHandler) List(c *gin.Context) {
var filters dto.DocumentFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var documents []models.Document
var total int64
query := h.db.Model(&models.Document{}).
Preload("Residence").
Preload("CreatedBy")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where(
"title ILIKE ? OR description ILIKE ? OR vendor ILIKE ?",
search, search, search,
)
}
// Apply filters
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.ResidenceID != nil {
query = query.Where("residence_id = ?", *filters.ResidenceID)
}
if filters.DocumentType != nil {
query = query.Where("document_type = ?", *filters.DocumentType)
}
// 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(&documents).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
return
}
// Build response
responses := make([]dto.DocumentResponse, len(documents))
for i, doc := range documents {
responses[i] = h.toDocumentResponse(&doc)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/documents/:id
func (h *AdminDocumentHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
var document models.Document
if err := h.db.
Preload("Residence").
Preload("CreatedBy").
Preload("Task").
First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document"})
return
}
response := dto.DocumentDetailResponse{
DocumentResponse: h.toDocumentResponse(&document),
}
if document.Task != nil {
response.TaskTitle = &document.Task.Title
}
c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/documents/:id
func (h *AdminDocumentHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
var document models.Document
if err := h.db.First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document"})
return
}
var req dto.UpdateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
document.Title = *req.Title
}
if req.Description != nil {
document.Description = *req.Description
}
if req.Vendor != nil {
document.Vendor = *req.Vendor
}
if req.SerialNumber != nil {
document.SerialNumber = *req.SerialNumber
}
if req.ModelNumber != nil {
document.ModelNumber = *req.ModelNumber
}
if req.IsActive != nil {
document.IsActive = *req.IsActive
}
if err := h.db.Save(&document).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update document"})
return
}
h.db.Preload("Residence").Preload("CreatedBy").First(&document, id)
c.JSON(http.StatusOK, h.toDocumentResponse(&document))
}
// Create handles POST /api/admin/documents
func (h *AdminDocumentHandler) Create(c *gin.Context) {
var req dto.CreateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify residence exists
var residence models.Residence
if err := h.db.First(&residence, req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
}
// Verify created_by user exists
var creator models.User
if err := h.db.First(&creator, req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Creator user not found"})
return
}
documentType := models.DocumentTypeGeneral
if req.DocumentType != "" {
documentType = models.DocumentType(req.DocumentType)
}
document := models.Document{
ResidenceID: req.ResidenceID,
CreatedByID: req.CreatedByID,
Title: req.Title,
Description: req.Description,
DocumentType: documentType,
FileURL: req.FileURL,
FileName: req.FileName,
FileSize: req.FileSize,
MimeType: req.MimeType,
Vendor: req.Vendor,
SerialNumber: req.SerialNumber,
ModelNumber: req.ModelNumber,
TaskID: req.TaskID,
IsActive: true,
}
if req.PurchaseDate != nil {
purchaseDate, err := time.Parse("2006-01-02", *req.PurchaseDate)
if err == nil {
document.PurchaseDate = &purchaseDate
}
}
if req.ExpiryDate != nil {
expiryDate, err := time.Parse("2006-01-02", *req.ExpiryDate)
if err == nil {
document.ExpiryDate = &expiryDate
}
}
if req.PurchasePrice != nil {
d := decimal.NewFromFloat(*req.PurchasePrice)
document.PurchasePrice = &d
}
if err := h.db.Create(&document).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
return
}
h.db.Preload("Residence").Preload("CreatedBy").First(&document, document.ID)
c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
}
// Delete handles DELETE /api/admin/documents/:id
func (h *AdminDocumentHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
}
var document models.Document
if err := h.db.First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document"})
return
}
// Soft delete
document.IsActive = false
if err := h.db.Save(&document).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Document deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/documents/bulk
func (h *AdminDocumentHandler) 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
}
// Soft delete - deactivate all
if err := h.db.Model(&models.Document{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete documents"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Documents deactivated successfully", "count": len(req.IDs)})
}
func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.DocumentResponse {
response := dto.DocumentResponse{
ID: doc.ID,
ResidenceID: doc.ResidenceID,
Title: doc.Title,
Description: doc.Description,
DocumentType: string(doc.DocumentType),
FileName: doc.FileName,
FileURL: doc.FileURL,
Vendor: doc.Vendor,
IsActive: doc.IsActive,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if doc.Residence.ID != 0 {
response.ResidenceName = doc.Residence.Name
}
if doc.ExpiryDate != nil {
expiryDate := doc.ExpiryDate.Format("2006-01-02")
response.ExpiryDate = &expiryDate
}
if doc.PurchaseDate != nil {
purchaseDate := doc.PurchaseDate.Format("2006-01-02")
response.PurchaseDate = &purchaseDate
}
return response
}

View File

@@ -0,0 +1,802 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminLookupHandler handles admin lookup table management endpoints
type AdminLookupHandler struct {
db *gorm.DB
}
// NewAdminLookupHandler creates a new admin lookup handler
func NewAdminLookupHandler(db *gorm.DB) *AdminLookupHandler {
return &AdminLookupHandler{db: db}
}
// ========== Task Categories ==========
type TaskCategoryResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
}
type CreateUpdateCategoryRequest struct {
Name string `json:"name" binding:"required,max=50"`
Description string `json:"description"`
Icon string `json:"icon" binding:"max=50"`
Color string `json:"color" binding:"max=7"`
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListCategories(c *gin.Context) {
var categories []models.TaskCategory
if err := h.db.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
return
}
responses := make([]TaskCategoryResponse, len(categories))
for i, cat := range categories {
responses[i] = TaskCategoryResponse{
ID: cat.ID,
Name: cat.Name,
Description: cat.Description,
Icon: cat.Icon,
Color: cat.Color,
DisplayOrder: cat.DisplayOrder,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateCategory(c *gin.Context) {
var req CreateUpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category := models.TaskCategory{
Name: req.Name,
Description: req.Description,
Icon: req.Icon,
Color: req.Color,
}
if req.DisplayOrder != nil {
category.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
return
}
c.JSON(http.StatusCreated, TaskCategoryResponse{
ID: category.ID,
Name: category.Name,
Description: category.Description,
Icon: category.Icon,
Color: category.Color,
DisplayOrder: category.DisplayOrder,
})
}
func (h *AdminLookupHandler) UpdateCategory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
}
var category models.TaskCategory
if err := h.db.First(&category, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch category"})
return
}
var req CreateUpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category.Name = req.Name
category.Description = req.Description
category.Icon = req.Icon
category.Color = req.Color
if req.DisplayOrder != nil {
category.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Save(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update category"})
return
}
c.JSON(http.StatusOK, TaskCategoryResponse{
ID: category.ID,
Name: category.Name,
Description: category.Description,
Icon: category.Icon,
Color: category.Color,
DisplayOrder: category.DisplayOrder,
})
}
func (h *AdminLookupHandler) DeleteCategory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
}
// Check if category is in use
var count int64
h.db.Model(&models.Task{}).Where("category_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete category that is in use by tasks"})
return
}
if err := h.db.Delete(&models.TaskCategory{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete category"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Category deleted successfully"})
}
// ========== Task Priorities ==========
type TaskPriorityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
}
type CreateUpdatePriorityRequest struct {
Name string `json:"name" binding:"required,max=20"`
Level int `json:"level" binding:"required,min=1,max=10"`
Color string `json:"color" binding:"max=7"`
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListPriorities(c *gin.Context) {
var priorities []models.TaskPriority
if err := h.db.Order("display_order ASC, level ASC").Find(&priorities).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch priorities"})
return
}
responses := make([]TaskPriorityResponse, len(priorities))
for i, p := range priorities {
responses[i] = TaskPriorityResponse{
ID: p.ID,
Name: p.Name,
Level: p.Level,
Color: p.Color,
DisplayOrder: p.DisplayOrder,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreatePriority(c *gin.Context) {
var req CreateUpdatePriorityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
priority := models.TaskPriority{
Name: req.Name,
Level: req.Level,
Color: req.Color,
}
if req.DisplayOrder != nil {
priority.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Create(&priority).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create priority"})
return
}
c.JSON(http.StatusCreated, TaskPriorityResponse{
ID: priority.ID,
Name: priority.Name,
Level: priority.Level,
Color: priority.Color,
DisplayOrder: priority.DisplayOrder,
})
}
func (h *AdminLookupHandler) UpdatePriority(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid priority ID"})
return
}
var priority models.TaskPriority
if err := h.db.First(&priority, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Priority not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch priority"})
return
}
var req CreateUpdatePriorityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
priority.Name = req.Name
priority.Level = req.Level
priority.Color = req.Color
if req.DisplayOrder != nil {
priority.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Save(&priority).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update priority"})
return
}
c.JSON(http.StatusOK, TaskPriorityResponse{
ID: priority.ID,
Name: priority.Name,
Level: priority.Level,
Color: priority.Color,
DisplayOrder: priority.DisplayOrder,
})
}
func (h *AdminLookupHandler) DeletePriority(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid priority ID"})
return
}
var count int64
h.db.Model(&models.Task{}).Where("priority_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete priority that is in use by tasks"})
return
}
if err := h.db.Delete(&models.TaskPriority{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete priority"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"})
}
// ========== Task Statuses ==========
type TaskStatusResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
}
type CreateUpdateStatusRequest struct {
Name string `json:"name" binding:"required,max=20"`
Description string `json:"description"`
Color string `json:"color" binding:"max=7"`
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListStatuses(c *gin.Context) {
var statuses []models.TaskStatus
if err := h.db.Order("display_order ASC, name ASC").Find(&statuses).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statuses"})
return
}
responses := make([]TaskStatusResponse, len(statuses))
for i, s := range statuses {
responses[i] = TaskStatusResponse{
ID: s.ID,
Name: s.Name,
Description: s.Description,
Color: s.Color,
DisplayOrder: s.DisplayOrder,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateStatus(c *gin.Context) {
var req CreateUpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status := models.TaskStatus{
Name: req.Name,
Description: req.Description,
Color: req.Color,
}
if req.DisplayOrder != nil {
status.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Create(&status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create status"})
return
}
c.JSON(http.StatusCreated, TaskStatusResponse{
ID: status.ID,
Name: status.Name,
Description: status.Description,
Color: status.Color,
DisplayOrder: status.DisplayOrder,
})
}
func (h *AdminLookupHandler) UpdateStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status ID"})
return
}
var status models.TaskStatus
if err := h.db.First(&status, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Status not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch status"})
return
}
var req CreateUpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status.Name = req.Name
status.Description = req.Description
status.Color = req.Color
if req.DisplayOrder != nil {
status.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Save(&status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status"})
return
}
c.JSON(http.StatusOK, TaskStatusResponse{
ID: status.ID,
Name: status.Name,
Description: status.Description,
Color: status.Color,
DisplayOrder: status.DisplayOrder,
})
}
func (h *AdminLookupHandler) DeleteStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status ID"})
return
}
var count int64
h.db.Model(&models.Task{}).Where("status_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete status that is in use by tasks"})
return
}
if err := h.db.Delete(&models.TaskStatus{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Status deleted successfully"})
}
// ========== Task Frequencies ==========
type TaskFrequencyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Days *int `json:"days"`
DisplayOrder int `json:"display_order"`
}
type CreateUpdateFrequencyRequest struct {
Name string `json:"name" binding:"required,max=20"`
Days *int `json:"days"`
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListFrequencies(c *gin.Context) {
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch frequencies"})
return
}
responses := make([]TaskFrequencyResponse, len(frequencies))
for i, f := range frequencies {
responses[i] = TaskFrequencyResponse{
ID: f.ID,
Name: f.Name,
Days: f.Days,
DisplayOrder: f.DisplayOrder,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateFrequency(c *gin.Context) {
var req CreateUpdateFrequencyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
frequency := models.TaskFrequency{
Name: req.Name,
Days: req.Days,
}
if req.DisplayOrder != nil {
frequency.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Create(&frequency).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create frequency"})
return
}
c.JSON(http.StatusCreated, TaskFrequencyResponse{
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
DisplayOrder: frequency.DisplayOrder,
})
}
func (h *AdminLookupHandler) UpdateFrequency(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid frequency ID"})
return
}
var frequency models.TaskFrequency
if err := h.db.First(&frequency, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Frequency not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch frequency"})
return
}
var req CreateUpdateFrequencyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
frequency.Name = req.Name
frequency.Days = req.Days
if req.DisplayOrder != nil {
frequency.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Save(&frequency).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update frequency"})
return
}
c.JSON(http.StatusOK, TaskFrequencyResponse{
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
DisplayOrder: frequency.DisplayOrder,
})
}
func (h *AdminLookupHandler) DeleteFrequency(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid frequency ID"})
return
}
var count int64
h.db.Model(&models.Task{}).Where("frequency_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete frequency that is in use by tasks"})
return
}
if err := h.db.Delete(&models.TaskFrequency{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete frequency"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Frequency deleted successfully"})
}
// ========== Residence Types ==========
type ResidenceTypeResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type CreateUpdateResidenceTypeRequest struct {
Name string `json:"name" binding:"required,max=20"`
}
func (h *AdminLookupHandler) ListResidenceTypes(c *gin.Context) {
var types []models.ResidenceType
if err := h.db.Order("name ASC").Find(&types).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence types"})
return
}
responses := make([]ResidenceTypeResponse, len(types))
for i, t := range types {
responses[i] = ResidenceTypeResponse{
ID: t.ID,
Name: t.Name,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateResidenceType(c *gin.Context) {
var req CreateUpdateResidenceTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
residenceType := models.ResidenceType{Name: req.Name}
if err := h.db.Create(&residenceType).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create residence type"})
return
}
c.JSON(http.StatusCreated, ResidenceTypeResponse{
ID: residenceType.ID,
Name: residenceType.Name,
})
}
func (h *AdminLookupHandler) UpdateResidenceType(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence type ID"})
return
}
var residenceType models.ResidenceType
if err := h.db.First(&residenceType, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence type not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence type"})
return
}
var req CreateUpdateResidenceTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
residenceType.Name = req.Name
if err := h.db.Save(&residenceType).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update residence type"})
return
}
c.JSON(http.StatusOK, ResidenceTypeResponse{
ID: residenceType.ID,
Name: residenceType.Name,
})
}
func (h *AdminLookupHandler) DeleteResidenceType(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence type ID"})
return
}
var count int64
h.db.Model(&models.Residence{}).Where("property_type_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete residence type that is in use"})
return
}
if err := h.db.Delete(&models.ResidenceType{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residence type"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Residence type deleted successfully"})
}
// ========== Contractor Specialties ==========
type ContractorSpecialtyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
DisplayOrder int `json:"display_order"`
}
type CreateUpdateSpecialtyRequest struct {
Name string `json:"name" binding:"required,max=50"`
Description string `json:"description"`
Icon string `json:"icon" binding:"max=50"`
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListSpecialties(c *gin.Context) {
var specialties []models.ContractorSpecialty
if err := h.db.Order("display_order ASC, name ASC").Find(&specialties).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch specialties"})
return
}
responses := make([]ContractorSpecialtyResponse, len(specialties))
for i, s := range specialties {
responses[i] = ContractorSpecialtyResponse{
ID: s.ID,
Name: s.Name,
Description: s.Description,
Icon: s.Icon,
DisplayOrder: s.DisplayOrder,
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateSpecialty(c *gin.Context) {
var req CreateUpdateSpecialtyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
specialty := models.ContractorSpecialty{
Name: req.Name,
Description: req.Description,
Icon: req.Icon,
}
if req.DisplayOrder != nil {
specialty.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Create(&specialty).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create specialty"})
return
}
c.JSON(http.StatusCreated, ContractorSpecialtyResponse{
ID: specialty.ID,
Name: specialty.Name,
Description: specialty.Description,
Icon: specialty.Icon,
DisplayOrder: specialty.DisplayOrder,
})
}
func (h *AdminLookupHandler) UpdateSpecialty(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid specialty ID"})
return
}
var specialty models.ContractorSpecialty
if err := h.db.First(&specialty, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Specialty not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch specialty"})
return
}
var req CreateUpdateSpecialtyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
specialty.Name = req.Name
specialty.Description = req.Description
specialty.Icon = req.Icon
if req.DisplayOrder != nil {
specialty.DisplayOrder = *req.DisplayOrder
}
if err := h.db.Save(&specialty).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update specialty"})
return
}
c.JSON(http.StatusOK, ContractorSpecialtyResponse{
ID: specialty.ID,
Name: specialty.Name,
Description: specialty.Description,
Icon: specialty.Icon,
DisplayOrder: specialty.DisplayOrder,
})
}
func (h *AdminLookupHandler) DeleteSpecialty(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid specialty ID"})
return
}
// Check if in use via many-to-many relationship
var count int64
h.db.Table("task_contractor_specialties").Where("contractorspecialty_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete specialty that is in use by contractors"})
return
}
if err := h.db.Delete(&models.ContractorSpecialty{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete specialty"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Specialty deleted successfully"})
}
// Ensure dto import is used
var _ = dto.PaginationParams{}

View File

@@ -0,0 +1,404 @@
package handlers
import (
"context"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
"github.com/treytartt/mycrib-api/internal/push"
"github.com/treytartt/mycrib-api/internal/services"
)
// AdminNotificationHandler handles admin notification management endpoints
type AdminNotificationHandler struct {
db *gorm.DB
emailService *services.EmailService
pushClient *push.GorushClient
}
// NewAdminNotificationHandler creates a new admin notification handler
func NewAdminNotificationHandler(db *gorm.DB, emailService *services.EmailService, pushClient *push.GorushClient) *AdminNotificationHandler {
return &AdminNotificationHandler{
db: db,
emailService: emailService,
pushClient: pushClient,
}
}
// List handles GET /api/admin/notifications
func (h *AdminNotificationHandler) List(c *gin.Context) {
var filters dto.NotificationFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var notifications []models.Notification
var total int64
query := h.db.Model(&models.Notification{}).
Preload("User")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where("title ILIKE ? OR body ILIKE ?", search, search)
}
// Apply filters
if filters.UserID != nil {
query = query.Where("user_id = ?", *filters.UserID)
}
if filters.NotificationType != nil {
query = query.Where("notification_type = ?", *filters.NotificationType)
}
if filters.Sent != nil {
query = query.Where("sent = ?", *filters.Sent)
}
if filters.Read != nil {
query = query.Where("read = ?", *filters.Read)
}
// 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(&notifications).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"})
return
}
// Build response
responses := make([]dto.NotificationResponse, len(notifications))
for i, notif := range notifications {
responses[i] = h.toNotificationResponse(&notif)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/notifications/:id
func (h *AdminNotificationHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
}
var notification models.Notification
if err := h.db.
Preload("User").
First(&notification, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification"})
return
}
c.JSON(http.StatusOK, h.toNotificationDetailResponse(&notification))
}
// Delete handles DELETE /api/admin/notifications/:id
func (h *AdminNotificationHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
}
var notification models.Notification
if err := h.db.First(&notification, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification"})
return
}
// Hard delete notifications
if err := h.db.Delete(&notification).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification deleted successfully"})
}
// Update handles PUT /api/admin/notifications/:id
func (h *AdminNotificationHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
}
var notification models.Notification
if err := h.db.First(&notification, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification"})
return
}
var req dto.UpdateNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
now := time.Now().UTC()
if req.Title != nil {
notification.Title = *req.Title
}
if req.Body != nil {
notification.Body = *req.Body
}
if req.Read != nil {
notification.Read = *req.Read
if *req.Read && notification.ReadAt == nil {
notification.ReadAt = &now
} else if !*req.Read {
notification.ReadAt = nil
}
}
if err := h.db.Save(&notification).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification"})
return
}
h.db.Preload("User").First(&notification, id)
c.JSON(http.StatusOK, h.toNotificationResponse(&notification))
}
// GetStats handles GET /api/admin/notifications/stats
func (h *AdminNotificationHandler) GetStats(c *gin.Context) {
var total, sent, read, pending int64
h.db.Model(&models.Notification{}).Count(&total)
h.db.Model(&models.Notification{}).Where("sent = ?", true).Count(&sent)
h.db.Model(&models.Notification{}).Where("read = ?", true).Count(&read)
h.db.Model(&models.Notification{}).Where("sent = ?", false).Count(&pending)
c.JSON(http.StatusOK, gin.H{
"total": total,
"sent": sent,
"read": read,
"pending": pending,
})
}
func (h *AdminNotificationHandler) toNotificationResponse(notif *models.Notification) dto.NotificationResponse {
response := dto.NotificationResponse{
ID: notif.ID,
UserID: notif.UserID,
NotificationType: string(notif.NotificationType),
Title: notif.Title,
Body: notif.Body,
Sent: notif.Sent,
Read: notif.Read,
CreatedAt: notif.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if notif.User.ID != 0 {
response.UserEmail = notif.User.Email
response.Username = notif.User.Username
}
if notif.SentAt != nil {
sentAt := notif.SentAt.Format("2006-01-02T15:04:05Z")
response.SentAt = &sentAt
}
if notif.ReadAt != nil {
readAt := notif.ReadAt.Format("2006-01-02T15:04:05Z")
response.ReadAt = &readAt
}
return response
}
func (h *AdminNotificationHandler) toNotificationDetailResponse(notif *models.Notification) dto.NotificationDetailResponse {
return dto.NotificationDetailResponse{
NotificationResponse: h.toNotificationResponse(notif),
Data: notif.Data,
TaskID: notif.TaskID,
}
}
// SendTestNotification handles POST /api/admin/notifications/send-test
func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
var req dto.SendTestNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify user exists
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
// Get user's device tokens
var iosDevices []models.APNSDevice
var androidDevices []models.GCMDevice
h.db.Where("user_id = ? AND active = ?", req.UserID, true).Find(&iosDevices)
h.db.Where("user_id = ? AND active = ?", req.UserID, true).Find(&androidDevices)
if len(iosDevices) == 0 && len(androidDevices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no registered devices"})
return
}
// Create notification record
now := time.Now().UTC()
notification := models.Notification{
UserID: req.UserID,
NotificationType: models.NotificationType("test"),
Title: req.Title,
Body: req.Body,
Data: `{"test": true}`,
Sent: false,
}
if err := h.db.Create(&notification).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create notification record"})
return
}
// Collect tokens
var iosTokens, androidTokens []string
for _, d := range iosDevices {
iosTokens = append(iosTokens, d.RegistrationID)
}
for _, d := range androidDevices {
androidTokens = append(androidTokens, d.RegistrationID)
}
// Send push notification
if h.pushClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pushData := map[string]string{
"notification_id": strconv.FormatUint(uint64(notification.ID), 10),
"test": "true",
}
err := h.pushClient.SendToAll(ctx, iosTokens, androidTokens, req.Title, req.Body, pushData)
if err != nil {
// Update notification with error
h.db.Model(&notification).Updates(map[string]interface{}{
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to send push notification",
"details": err.Error(),
})
return
}
} else {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Push notification service not configured"})
return
}
// Mark as sent
h.db.Model(&notification).Updates(map[string]interface{}{
"sent": true,
"sent_at": now,
})
c.JSON(http.StatusOK, gin.H{
"message": "Test notification sent successfully",
"notification_id": notification.ID,
"devices": gin.H{
"ios": len(iosTokens),
"android": len(androidTokens),
},
})
}
// SendTestEmail handles POST /api/admin/emails/send-test
func (h *AdminNotificationHandler) SendTestEmail(c *gin.Context) {
var req dto.SendTestEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify user exists
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
if user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no email address"})
return
}
// Send email
if h.emailService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Email service not configured"})
return
}
// Create HTML body with basic styling
htmlBody := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>` + req.Subject + `</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">` + req.Subject + `</h2>
<div style="color: #666; line-height: 1.6;">` + req.Body + `</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px;">This is a test email sent from MyCrib Admin Panel.</p>
</body>
</html>`
err := h.emailService.SendEmail(user.Email, req.Subject, htmlBody, req.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to send email",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Test email sent successfully",
"to": user.Email,
})
}

View File

@@ -0,0 +1,290 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminNotificationPrefsHandler handles notification preference management
type AdminNotificationPrefsHandler struct {
db *gorm.DB
}
// NewAdminNotificationPrefsHandler creates a new handler
func NewAdminNotificationPrefsHandler(db *gorm.DB) *AdminNotificationPrefsHandler {
return &AdminNotificationPrefsHandler{db: db}
}
// NotificationPrefResponse represents a notification preference in the admin API
type NotificationPrefResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
TaskDueSoon bool `json:"task_due_soon"`
TaskOverdue bool `json:"task_overdue"`
TaskCompleted bool `json:"task_completed"`
TaskAssigned bool `json:"task_assigned"`
ResidenceShared bool `json:"residence_shared"`
WarrantyExpiring bool `json:"warranty_expiring"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// List handles GET /api/admin/notification-prefs
func (h *AdminNotificationPrefsHandler) 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 prefs []models.NotificationPreference
var total int64
query := h.db.Model(&models.NotificationPreference{})
// Apply search on user data via subquery
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where("user_id IN (?)",
h.db.Model(&models.User{}).Select("id").Where(
"username ILIKE ? OR email 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(&prefs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preferences"})
return
}
// Get user info for each preference
userIDs := make([]uint, len(prefs))
for i, p := range prefs {
userIDs[i] = p.UserID
}
var users []models.User
userMap := make(map[uint]models.User)
if len(userIDs) > 0 {
h.db.Where("id IN ?", userIDs).Find(&users)
for _, u := range users {
userMap[u.ID] = u
}
}
// Build response
responses := make([]NotificationPrefResponse, len(prefs))
for i, pref := range prefs {
user := userMap[pref.UserID]
responses[i] = NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/notification-prefs/:id
func (h *AdminNotificationPrefsHandler) 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 pref models.NotificationPreference
if err := h.db.First(&pref, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preference"})
return
}
var user models.User
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
// UpdateNotificationPrefRequest represents an update request
type UpdateNotificationPrefRequest struct {
TaskDueSoon *bool `json:"task_due_soon"`
TaskOverdue *bool `json:"task_overdue"`
TaskCompleted *bool `json:"task_completed"`
TaskAssigned *bool `json:"task_assigned"`
ResidenceShared *bool `json:"residence_shared"`
WarrantyExpiring *bool `json:"warranty_expiring"`
}
// Update handles PUT /api/admin/notification-prefs/:id
func (h *AdminNotificationPrefsHandler) 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 pref models.NotificationPreference
if err := h.db.First(&pref, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preference"})
return
}
var req UpdateNotificationPrefRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply updates
if req.TaskDueSoon != nil {
pref.TaskDueSoon = *req.TaskDueSoon
}
if req.TaskOverdue != nil {
pref.TaskOverdue = *req.TaskOverdue
}
if req.TaskCompleted != nil {
pref.TaskCompleted = *req.TaskCompleted
}
if req.TaskAssigned != nil {
pref.TaskAssigned = *req.TaskAssigned
}
if req.ResidenceShared != nil {
pref.ResidenceShared = *req.ResidenceShared
}
if req.WarrantyExpiring != nil {
pref.WarrantyExpiring = *req.WarrantyExpiring
}
if err := h.db.Save(&pref).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"})
return
}
var user models.User
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
// Delete handles DELETE /api/admin/notification-prefs/:id
func (h *AdminNotificationPrefsHandler) 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.NotificationPreference{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification preference"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification preference deleted"})
}
// GetByUser handles GET /api/admin/notification-prefs/user/:user_id
func (h *AdminNotificationPrefsHandler) 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 pref models.NotificationPreference
if err := h.db.Where("user_id = ?", userID).First(&pref).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found for this user"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preference"})
return
}
var user models.User
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}

View File

@@ -0,0 +1,321 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminResidenceHandler handles admin residence management endpoints
type AdminResidenceHandler struct {
db *gorm.DB
}
// NewAdminResidenceHandler creates a new admin residence handler
func NewAdminResidenceHandler(db *gorm.DB) *AdminResidenceHandler {
return &AdminResidenceHandler{db: db}
}
// List handles GET /api/admin/residences
func (h *AdminResidenceHandler) List(c *gin.Context) {
var filters dto.ResidenceFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var residences []models.Residence
var total int64
query := h.db.Model(&models.Residence{}).
Preload("Owner").
Preload("PropertyType")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where(
"name ILIKE ? OR street_address ILIKE ? OR city ILIKE ?",
search, search, search,
)
}
// Apply filters
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.OwnerID != nil {
query = query.Where("owner_id = ?", *filters.OwnerID)
}
// 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(&residences).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residences"})
return
}
// Build response
responses := make([]dto.ResidenceResponse, len(residences))
for i, res := range residences {
responses[i] = h.toResidenceResponse(&res)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/residences/:id
func (h *AdminResidenceHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"})
return
}
var residence models.Residence
if err := h.db.Preload("Owner").Preload("PropertyType").Preload("Users").First(&residence, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence"})
return
}
response := dto.ResidenceDetailResponse{
ResidenceResponse: h.toResidenceResponse(&residence),
}
// Add owner info
response.Owner = &dto.UserSummary{
ID: residence.Owner.ID,
Username: residence.Owner.Username,
Email: residence.Owner.Email,
}
// Add shared users
for _, user := range residence.Users {
response.SharedUsers = append(response.SharedUsers, dto.UserSummary{
ID: user.ID,
Username: user.Username,
Email: user.Email,
})
}
// Get counts
var taskCount, documentCount int64
h.db.Model(&models.Task{}).Where("residence_id = ?", residence.ID).Count(&taskCount)
h.db.Model(&models.Document{}).Where("residence_id = ?", residence.ID).Count(&documentCount)
response.TaskCount = int(taskCount)
response.DocumentCount = int(documentCount)
c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/residences/:id
func (h *AdminResidenceHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"})
return
}
var residence models.Residence
if err := h.db.First(&residence, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence"})
return
}
var req dto.UpdateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
residence.Name = *req.Name
}
if req.StreetAddress != nil {
residence.StreetAddress = *req.StreetAddress
}
if req.City != nil {
residence.City = *req.City
}
if req.StateProvince != nil {
residence.StateProvince = *req.StateProvince
}
if req.PostalCode != nil {
residence.PostalCode = *req.PostalCode
}
if req.Country != nil {
residence.Country = *req.Country
}
if req.IsActive != nil {
residence.IsActive = *req.IsActive
}
if req.IsPrimary != nil {
residence.IsPrimary = *req.IsPrimary
}
if err := h.db.Save(&residence).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update residence"})
return
}
h.db.Preload("Owner").Preload("PropertyType").First(&residence, id)
c.JSON(http.StatusOK, h.toResidenceResponse(&residence))
}
// Create handles POST /api/admin/residences
func (h *AdminResidenceHandler) Create(c *gin.Context) {
var req dto.CreateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify owner exists
var owner models.User
if err := h.db.First(&owner, req.OwnerID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Owner not found"})
return
}
residence := models.Residence{
OwnerID: req.OwnerID,
Name: req.Name,
PropertyTypeID: req.PropertyTypeID,
StreetAddress: req.StreetAddress,
ApartmentUnit: req.ApartmentUnit,
City: req.City,
StateProvince: req.StateProvince,
PostalCode: req.PostalCode,
Country: req.Country,
Description: req.Description,
IsPrimary: req.IsPrimary,
IsActive: true,
}
if req.Bedrooms != nil {
residence.Bedrooms = req.Bedrooms
}
if req.Bathrooms != nil {
d := decimal.NewFromFloat(*req.Bathrooms)
residence.Bathrooms = &d
}
if req.SquareFootage != nil {
residence.SquareFootage = req.SquareFootage
}
if req.YearBuilt != nil {
residence.YearBuilt = req.YearBuilt
}
if err := h.db.Create(&residence).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create residence"})
return
}
h.db.Preload("Owner").Preload("PropertyType").First(&residence, residence.ID)
c.JSON(http.StatusCreated, h.toResidenceResponse(&residence))
}
// Delete handles DELETE /api/admin/residences/:id
func (h *AdminResidenceHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"})
return
}
var residence models.Residence
if err := h.db.First(&residence, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence"})
return
}
// Soft delete
residence.IsActive = false
if err := h.db.Save(&residence).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residence"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Residence deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/residences/bulk
func (h *AdminResidenceHandler) 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
}
// Soft delete - deactivate all
if err := h.db.Model(&models.Residence{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residences"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Residences deactivated successfully", "count": len(req.IDs)})
}
func (h *AdminResidenceHandler) toResidenceResponse(res *models.Residence) dto.ResidenceResponse {
response := dto.ResidenceResponse{
ID: res.ID,
Name: res.Name,
OwnerID: res.OwnerID,
StreetAddress: res.StreetAddress,
City: res.City,
StateProvince: res.StateProvince,
PostalCode: res.PostalCode,
Country: res.Country,
IsPrimary: res.IsPrimary,
IsActive: res.IsActive,
CreatedAt: res.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if res.Owner.ID != 0 {
response.OwnerName = res.Owner.Username
}
if res.PropertyType != nil {
response.PropertyType = &res.PropertyType.Name
}
if res.Bedrooms != nil {
response.Bedrooms = res.Bedrooms
}
if res.Bathrooms != nil {
f, _ := res.Bathrooms.Float64()
response.Bathrooms = &f
}
if res.SquareFootage != nil {
response.SquareFootage = res.SquareFootage
}
return response
}

View File

@@ -0,0 +1,207 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminSubscriptionHandler handles admin subscription management endpoints
type AdminSubscriptionHandler struct {
db *gorm.DB
}
// NewAdminSubscriptionHandler creates a new admin subscription handler
func NewAdminSubscriptionHandler(db *gorm.DB) *AdminSubscriptionHandler {
return &AdminSubscriptionHandler{db: db}
}
// List handles GET /api/admin/subscriptions
func (h *AdminSubscriptionHandler) List(c *gin.Context) {
var filters dto.SubscriptionFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var subscriptions []models.UserSubscription
var total int64
query := h.db.Model(&models.UserSubscription{}).
Preload("User")
// Apply search (search by user email)
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN users ON users.id = subscription_usersubscription.user_id").
Where("users.email ILIKE ? OR users.username ILIKE ?", search, search)
}
// Apply filters
if filters.UserID != nil {
query = query.Where("user_id = ?", *filters.UserID)
}
if filters.Tier != nil {
query = query.Where("tier = ?", *filters.Tier)
}
if filters.Platform != nil {
query = query.Where("platform = ?", *filters.Platform)
}
if filters.AutoRenew != nil {
query = query.Where("auto_renew = ?", *filters.AutoRenew)
}
if filters.Active != nil {
if *filters.Active {
query = query.Where("expires_at IS NULL OR expires_at > NOW()")
} else {
query = query.Where("expires_at IS NOT NULL AND expires_at <= NOW()")
}
}
// 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(&subscriptions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriptions"})
return
}
// Build response
responses := make([]dto.SubscriptionResponse, len(subscriptions))
for i, sub := range subscriptions {
responses[i] = h.toSubscriptionResponse(&sub)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/subscriptions/:id
func (h *AdminSubscriptionHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscription ID"})
return
}
var subscription models.UserSubscription
if err := h.db.
Preload("User").
First(&subscription, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"})
return
}
c.JSON(http.StatusOK, h.toSubscriptionDetailResponse(&subscription))
}
// Update handles PUT /api/admin/subscriptions/:id
func (h *AdminSubscriptionHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscription ID"})
return
}
var subscription models.UserSubscription
if err := h.db.First(&subscription, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"})
return
}
var req dto.UpdateSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Tier != nil {
subscription.Tier = models.SubscriptionTier(*req.Tier)
}
if req.AutoRenew != nil {
subscription.AutoRenew = *req.AutoRenew
}
if err := h.db.Save(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"})
return
}
h.db.Preload("User").First(&subscription, id)
c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
}
// GetStats handles GET /api/admin/subscriptions/stats
func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) {
var total, free, premium, pro int64
h.db.Model(&models.UserSubscription{}).Count(&total)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "free").Count(&free)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "premium").Count(&premium)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "pro").Count(&pro)
c.JSON(http.StatusOK, gin.H{
"total": total,
"free": free,
"premium": premium,
"pro": pro,
})
}
func (h *AdminSubscriptionHandler) toSubscriptionResponse(sub *models.UserSubscription) dto.SubscriptionResponse {
response := dto.SubscriptionResponse{
ID: sub.ID,
UserID: sub.UserID,
Tier: string(sub.Tier),
Platform: sub.Platform,
AutoRenew: sub.AutoRenew,
CreatedAt: sub.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if sub.User.ID != 0 {
response.UserEmail = sub.User.Email
response.Username = sub.User.Username
}
if sub.SubscribedAt != nil {
subscribedAt := sub.SubscribedAt.Format("2006-01-02T15:04:05Z")
response.SubscribedAt = &subscribedAt
}
if sub.ExpiresAt != nil {
expiresAt := sub.ExpiresAt.Format("2006-01-02T15:04:05Z")
response.ExpiresAt = &expiresAt
}
if sub.CancelledAt != nil {
cancelledAt := sub.CancelledAt.Format("2006-01-02T15:04:05Z")
response.CancelledAt = &cancelledAt
}
return response
}
func (h *AdminSubscriptionHandler) toSubscriptionDetailResponse(sub *models.UserSubscription) dto.SubscriptionDetailResponse {
return dto.SubscriptionDetailResponse{
SubscriptionResponse: h.toSubscriptionResponse(sub),
}
}

View File

@@ -0,0 +1,338 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminTaskHandler handles admin task management endpoints
type AdminTaskHandler struct {
db *gorm.DB
}
// NewAdminTaskHandler creates a new admin task handler
func NewAdminTaskHandler(db *gorm.DB) *AdminTaskHandler {
return &AdminTaskHandler{db: db}
}
// List handles GET /api/admin/tasks
func (h *AdminTaskHandler) List(c *gin.Context) {
var filters dto.TaskFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var tasks []models.Task
var total int64
query := h.db.Model(&models.Task{}).
Preload("Residence").
Preload("CreatedBy").
Preload("Category").
Preload("Priority").
Preload("Status")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where("title ILIKE ? OR description ILIKE ?", search, search)
}
// Apply filters
if filters.ResidenceID != nil {
query = query.Where("residence_id = ?", *filters.ResidenceID)
}
if filters.CategoryID != nil {
query = query.Where("category_id = ?", *filters.CategoryID)
}
if filters.PriorityID != nil {
query = query.Where("priority_id = ?", *filters.PriorityID)
}
if filters.StatusID != nil {
query = query.Where("status_id = ?", *filters.StatusID)
}
if filters.IsCancelled != nil {
query = query.Where("is_cancelled = ?", *filters.IsCancelled)
}
if filters.IsArchived != nil {
query = query.Where("is_archived = ?", *filters.IsArchived)
}
// 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(&tasks).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tasks"})
return
}
// Build response
responses := make([]dto.TaskResponse, len(tasks))
for i, task := range tasks {
responses[i] = h.toTaskResponse(&task)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/tasks/:id
func (h *AdminTaskHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
var task models.Task
if err := h.db.
Preload("Residence").
Preload("CreatedBy").
Preload("AssignedTo").
Preload("Category").
Preload("Priority").
Preload("Status").
Preload("Frequency").
Preload("Completions").
First(&task, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task"})
return
}
response := dto.TaskDetailResponse{
TaskResponse: h.toTaskResponse(&task),
}
if task.AssignedTo != nil {
response.AssignedTo = &dto.UserSummary{
ID: task.AssignedTo.ID,
Username: task.AssignedTo.Username,
Email: task.AssignedTo.Email,
}
}
response.CompletionCount = len(task.Completions)
c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/tasks/:id
func (h *AdminTaskHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
var task models.Task
if err := h.db.First(&task, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task"})
return
}
var req dto.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
task.Title = *req.Title
}
if req.Description != nil {
task.Description = *req.Description
}
if req.CategoryID != nil {
task.CategoryID = req.CategoryID
}
if req.PriorityID != nil {
task.PriorityID = req.PriorityID
}
if req.StatusID != nil {
task.StatusID = req.StatusID
}
if req.IsCancelled != nil {
task.IsCancelled = *req.IsCancelled
}
if req.IsArchived != nil {
task.IsArchived = *req.IsArchived
}
if err := h.db.Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update task"})
return
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, id)
c.JSON(http.StatusOK, h.toTaskResponse(&task))
}
// Create handles POST /api/admin/tasks
func (h *AdminTaskHandler) Create(c *gin.Context) {
var req dto.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify residence exists
var residence models.Residence
if err := h.db.First(&residence, req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
}
// Verify created_by user exists
var creator models.User
if err := h.db.First(&creator, req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Creator user not found"})
return
}
task := models.Task{
ResidenceID: req.ResidenceID,
CreatedByID: req.CreatedByID,
Title: req.Title,
Description: req.Description,
CategoryID: req.CategoryID,
PriorityID: req.PriorityID,
StatusID: req.StatusID,
FrequencyID: req.FrequencyID,
AssignedToID: req.AssignedToID,
ContractorID: req.ContractorID,
IsCancelled: false,
IsArchived: false,
}
if req.DueDate != nil {
dueDate, err := time.Parse("2006-01-02", *req.DueDate)
if err == nil {
task.DueDate = &dueDate
}
}
if req.EstimatedCost != nil {
d := decimal.NewFromFloat(*req.EstimatedCost)
task.EstimatedCost = &d
}
if err := h.db.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"})
return
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").Preload("Status").First(&task, task.ID)
c.JSON(http.StatusCreated, h.toTaskResponse(&task))
}
// Delete handles DELETE /api/admin/tasks/:id
func (h *AdminTaskHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
}
var task models.Task
if err := h.db.First(&task, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task"})
return
}
// Soft delete - archive and cancel
task.IsArchived = true
task.IsCancelled = true
if err := h.db.Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Task archived successfully"})
}
// BulkDelete handles DELETE /api/admin/tasks/bulk
func (h *AdminTaskHandler) 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
}
// Soft delete - archive and cancel all
if err := h.db.Model(&models.Task{}).Where("id IN ?", req.IDs).Updates(map[string]interface{}{
"is_archived": true,
"is_cancelled": true,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete tasks"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Tasks archived successfully", "count": len(req.IDs)})
}
func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse {
response := dto.TaskResponse{
ID: task.ID,
ResidenceID: task.ResidenceID,
Title: task.Title,
Description: task.Description,
IsCancelled: task.IsCancelled,
IsArchived: task.IsArchived,
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if task.Residence.ID != 0 {
response.ResidenceName = task.Residence.Name
}
if task.CreatedBy.ID != 0 {
response.CreatedByName = task.CreatedBy.Username
}
if task.Category != nil {
response.CategoryName = &task.Category.Name
}
if task.Priority != nil {
response.PriorityName = &task.Priority.Name
}
if task.Status != nil {
response.StatusName = &task.Status.Name
}
if task.DueDate != nil {
dueDate := task.DueDate.Format("2006-01-02")
response.DueDate = &dueDate
}
if task.EstimatedCost != nil {
cost, _ := task.EstimatedCost.Float64()
response.EstimatedCost = &cost
}
return response
}

View File

@@ -0,0 +1,349 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/dto"
"github.com/treytartt/mycrib-api/internal/models"
)
// AdminUserHandler handles admin user management endpoints
type AdminUserHandler struct {
db *gorm.DB
}
// NewAdminUserHandler creates a new admin user handler
func NewAdminUserHandler(db *gorm.DB) *AdminUserHandler {
return &AdminUserHandler{db: db}
}
// List handles GET /api/admin/users
func (h *AdminUserHandler) List(c *gin.Context) {
var filters dto.UserFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var users []models.User
var total int64
query := h.db.Model(&models.User{}).Preload("Profile")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Where(
"username ILIKE ? OR email ILIKE ? OR first_name ILIKE ? OR last_name ILIKE ?",
search, search, search, search,
)
}
// Apply filters
if filters.IsActive != nil {
query = query.Where("is_active = ?", *filters.IsActive)
}
if filters.IsStaff != nil {
query = query.Where("is_staff = ?", *filters.IsStaff)
}
if filters.IsSuperuser != nil {
query = query.Where("is_superuser = ?", *filters.IsSuperuser)
}
// Get total count
query.Count(&total)
// Apply sorting
sortBy := "date_joined"
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(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
return
}
// Build response
responses := make([]dto.UserResponse, len(users))
for i, user := range users {
responses[i] = h.toUserResponse(&user)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/users/:id
func (h *AdminUserHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var user models.User
if err := h.db.Preload("Profile").Preload("OwnedResidences").First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
// Build detailed response
response := dto.UserDetailResponse{
UserResponse: h.toUserResponse(&user),
}
// Add residences
for _, res := range user.OwnedResidences {
response.Residences = append(response.Residences, dto.ResidenceSummary{
ID: res.ID,
Name: res.Name,
IsOwner: true,
IsActive: res.IsActive,
})
}
c.JSON(http.StatusOK, response)
}
// Create handles POST /api/admin/users
func (h *AdminUserHandler) Create(c *gin.Context) {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if username exists
var count int64
h.db.Model(&models.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
}
// Check if email exists
h.db.Model(&models.User{}).Where("email = ?", req.Email).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already exists"})
return
}
user := models.User{
Username: req.Username,
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
IsActive: true,
IsStaff: false,
}
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
if req.IsStaff != nil {
user.IsStaff = *req.IsStaff
}
if req.IsSuperuser != nil {
user.IsSuperuser = *req.IsSuperuser
}
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
if err := h.db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// Create profile with phone number
profile := models.UserProfile{
UserID: user.ID,
PhoneNumber: req.PhoneNumber,
}
h.db.Create(&profile)
// Reload with profile
h.db.Preload("Profile").First(&user, user.ID)
c.JSON(http.StatusCreated, h.toUserResponse(&user))
}
// Update handles PUT /api/admin/users/:id
func (h *AdminUserHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var user models.User
if err := h.db.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
var req dto.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check username uniqueness if changing
if req.Username != nil && *req.Username != user.Username {
var count int64
h.db.Model(&models.User{}).Where("username = ? AND id != ?", *req.Username, id).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
}
user.Username = *req.Username
}
// Check email uniqueness if changing
if req.Email != nil && *req.Email != user.Email {
var count int64
h.db.Model(&models.User{}).Where("email = ? AND id != ?", *req.Email, id).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already exists"})
return
}
user.Email = *req.Email
}
if req.FirstName != nil {
user.FirstName = *req.FirstName
}
if req.LastName != nil {
user.LastName = *req.LastName
}
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
if req.IsStaff != nil {
user.IsStaff = *req.IsStaff
}
if req.IsSuperuser != nil {
user.IsSuperuser = *req.IsSuperuser
}
if req.Password != nil {
if err := user.SetPassword(*req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
}
if err := h.db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
// Update phone number in profile if provided
if req.PhoneNumber != nil {
var profile models.UserProfile
if err := h.db.Where("user_id = ?", user.ID).First(&profile).Error; err == nil {
profile.PhoneNumber = *req.PhoneNumber
h.db.Save(&profile)
}
}
h.db.Preload("Profile").First(&user, id)
c.JSON(http.StatusOK, h.toUserResponse(&user))
}
// Delete handles DELETE /api/admin/users/:id
func (h *AdminUserHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var user models.User
if err := h.db.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
// Soft delete - just deactivate
user.IsActive = false
if err := h.db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/users/bulk
func (h *AdminUserHandler) 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
}
// Soft delete - deactivate all
if err := h.db.Model(&models.User{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Users deactivated successfully", "count": len(req.IDs)})
}
// toUserResponse converts a User model to UserResponse DTO
func (h *AdminUserHandler) toUserResponse(user *models.User) dto.UserResponse {
response := dto.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
IsActive: user.IsActive,
IsStaff: user.IsStaff,
IsSuperuser: user.IsSuperuser,
DateJoined: user.DateJoined.Format("2006-01-02T15:04:05Z"),
}
if user.LastLogin != nil {
lastLogin := user.LastLogin.Format("2006-01-02T15:04:05Z")
response.LastLogin = &lastLogin
}
if user.Profile != nil {
response.Verified = user.Profile.Verified
if user.Profile.PhoneNumber != "" {
response.PhoneNumber = &user.Profile.PhoneNumber
}
}
// Get counts
var residenceCount, taskCount int64
h.db.Model(&models.Residence{}).Where("owner_id = ?", user.ID).Count(&residenceCount)
h.db.Model(&models.Task{}).Where("created_by_id = ?", user.ID).Count(&taskCount)
response.ResidenceCount = int(residenceCount)
response.TaskCount = int(taskCount)
return response
}

326
internal/admin/routes.go Normal file
View File

@@ -0,0 +1,326 @@
package admin
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/mycrib-api/internal/admin/handlers"
"github.com/treytartt/mycrib-api/internal/config"
"github.com/treytartt/mycrib-api/internal/middleware"
"github.com/treytartt/mycrib-api/internal/push"
"github.com/treytartt/mycrib-api/internal/repositories"
"github.com/treytartt/mycrib-api/internal/services"
)
// Dependencies holds optional services for admin routes
type Dependencies struct {
EmailService *services.EmailService
PushClient *push.GorushClient
}
// SetupRoutes configures all admin routes
func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Dependencies) {
// Create repositories
adminRepo := repositories.NewAdminRepository(db)
// Create handlers
authHandler := handlers.NewAdminAuthHandler(adminRepo, cfg)
// Admin API group
admin := router.Group("/api/admin")
{
// Public auth routes (no auth required)
auth := admin.Group("/auth")
{
auth.POST("/login", authHandler.Login)
}
// Protected routes (require admin JWT)
protected := admin.Group("")
protected.Use(middleware.AdminAuthMiddleware(cfg, adminRepo))
{
// Auth routes that require authentication
protectedAuth := protected.Group("/auth")
{
protectedAuth.POST("/logout", authHandler.Logout)
protectedAuth.GET("/me", authHandler.Me)
protectedAuth.POST("/refresh", authHandler.RefreshToken)
}
// User management
userHandler := handlers.NewAdminUserHandler(db)
users := protected.Group("/users")
{
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.GET("/:id", userHandler.Get)
users.PUT("/:id", userHandler.Update)
users.DELETE("/:id", userHandler.Delete)
users.DELETE("/bulk", userHandler.BulkDelete)
}
// Residence management
residenceHandler := handlers.NewAdminResidenceHandler(db)
residences := protected.Group("/residences")
{
residences.GET("", residenceHandler.List)
residences.POST("", residenceHandler.Create)
residences.DELETE("/bulk", residenceHandler.BulkDelete)
residences.GET("/:id", residenceHandler.Get)
residences.PUT("/:id", residenceHandler.Update)
residences.DELETE("/:id", residenceHandler.Delete)
}
// Task management
taskHandler := handlers.NewAdminTaskHandler(db)
tasks := protected.Group("/tasks")
{
tasks.GET("", taskHandler.List)
tasks.POST("", taskHandler.Create)
tasks.DELETE("/bulk", taskHandler.BulkDelete)
tasks.GET("/:id", taskHandler.Get)
tasks.PUT("/:id", taskHandler.Update)
tasks.DELETE("/:id", taskHandler.Delete)
}
// Contractor management
contractorHandler := handlers.NewAdminContractorHandler(db)
contractors := protected.Group("/contractors")
{
contractors.GET("", contractorHandler.List)
contractors.POST("", contractorHandler.Create)
contractors.DELETE("/bulk", contractorHandler.BulkDelete)
contractors.GET("/:id", contractorHandler.Get)
contractors.PUT("/:id", contractorHandler.Update)
contractors.DELETE("/:id", contractorHandler.Delete)
}
// Document management
documentHandler := handlers.NewAdminDocumentHandler(db)
documents := protected.Group("/documents")
{
documents.GET("", documentHandler.List)
documents.POST("", documentHandler.Create)
documents.DELETE("/bulk", documentHandler.BulkDelete)
documents.GET("/:id", documentHandler.Get)
documents.PUT("/:id", documentHandler.Update)
documents.DELETE("/:id", documentHandler.Delete)
}
// Notification management
var emailService *services.EmailService
var pushClient *push.GorushClient
if deps != nil {
emailService = deps.EmailService
pushClient = deps.PushClient
}
notificationHandler := handlers.NewAdminNotificationHandler(db, emailService, pushClient)
notifications := protected.Group("/notifications")
{
notifications.GET("", notificationHandler.List)
notifications.GET("/stats", notificationHandler.GetStats)
notifications.POST("/send-test", notificationHandler.SendTestNotification)
notifications.GET("/:id", notificationHandler.Get)
notifications.PUT("/:id", notificationHandler.Update)
notifications.DELETE("/:id", notificationHandler.Delete)
}
// Email test endpoint
emails := protected.Group("/emails")
{
emails.POST("/send-test", notificationHandler.SendTestEmail)
}
// Subscription management
subscriptionHandler := handlers.NewAdminSubscriptionHandler(db)
subscriptions := protected.Group("/subscriptions")
{
subscriptions.GET("", subscriptionHandler.List)
subscriptions.GET("/stats", subscriptionHandler.GetStats)
subscriptions.GET("/:id", subscriptionHandler.Get)
subscriptions.PUT("/:id", subscriptionHandler.Update)
}
// Dashboard stats
dashboardHandler := handlers.NewAdminDashboardHandler(db)
protected.GET("/dashboard/stats", dashboardHandler.GetStats)
// Auth token management
authTokenHandler := handlers.NewAdminAuthTokenHandler(db)
authTokens := protected.Group("/auth-tokens")
{
authTokens.GET("", authTokenHandler.List)
authTokens.DELETE("/bulk", authTokenHandler.BulkDelete)
authTokens.GET("/:id", authTokenHandler.Get)
authTokens.DELETE("/:id", authTokenHandler.Delete)
}
// Task completion management
completionHandler := handlers.NewAdminCompletionHandler(db)
completions := protected.Group("/completions")
{
completions.GET("", completionHandler.List)
completions.DELETE("/bulk", completionHandler.BulkDelete)
completions.GET("/:id", completionHandler.Get)
completions.PUT("/:id", completionHandler.Update)
completions.DELETE("/:id", completionHandler.Delete)
}
// Lookup tables management
lookupHandler := handlers.NewAdminLookupHandler(db)
// Task Categories
categories := protected.Group("/lookups/categories")
{
categories.GET("", lookupHandler.ListCategories)
categories.POST("", lookupHandler.CreateCategory)
categories.PUT("/:id", lookupHandler.UpdateCategory)
categories.DELETE("/:id", lookupHandler.DeleteCategory)
}
// Task Priorities
priorities := protected.Group("/lookups/priorities")
{
priorities.GET("", lookupHandler.ListPriorities)
priorities.POST("", lookupHandler.CreatePriority)
priorities.PUT("/:id", lookupHandler.UpdatePriority)
priorities.DELETE("/:id", lookupHandler.DeletePriority)
}
// Task Statuses
statuses := protected.Group("/lookups/statuses")
{
statuses.GET("", lookupHandler.ListStatuses)
statuses.POST("", lookupHandler.CreateStatus)
statuses.PUT("/:id", lookupHandler.UpdateStatus)
statuses.DELETE("/:id", lookupHandler.DeleteStatus)
}
// Task Frequencies
frequencies := protected.Group("/lookups/frequencies")
{
frequencies.GET("", lookupHandler.ListFrequencies)
frequencies.POST("", lookupHandler.CreateFrequency)
frequencies.PUT("/:id", lookupHandler.UpdateFrequency)
frequencies.DELETE("/:id", lookupHandler.DeleteFrequency)
}
// Residence Types
residenceTypes := protected.Group("/lookups/residence-types")
{
residenceTypes.GET("", lookupHandler.ListResidenceTypes)
residenceTypes.POST("", lookupHandler.CreateResidenceType)
residenceTypes.PUT("/:id", lookupHandler.UpdateResidenceType)
residenceTypes.DELETE("/:id", lookupHandler.DeleteResidenceType)
}
// Contractor Specialties
specialties := protected.Group("/lookups/specialties")
{
specialties.GET("", lookupHandler.ListSpecialties)
specialties.POST("", lookupHandler.CreateSpecialty)
specialties.PUT("/:id", lookupHandler.UpdateSpecialty)
specialties.DELETE("/:id", lookupHandler.DeleteSpecialty)
}
// Admin user management
adminUserHandler := handlers.NewAdminUserManagementHandler(db)
adminUsers := protected.Group("/admin-users")
{
adminUsers.GET("", adminUserHandler.List)
adminUsers.POST("", adminUserHandler.Create)
adminUsers.GET("/:id", adminUserHandler.Get)
adminUsers.PUT("/:id", adminUserHandler.Update)
adminUsers.DELETE("/:id", adminUserHandler.Delete)
}
// Notification preferences management
notifPrefsHandler := handlers.NewAdminNotificationPrefsHandler(db)
notifPrefs := protected.Group("/notification-prefs")
{
notifPrefs.GET("", notifPrefsHandler.List)
notifPrefs.GET("/:id", notifPrefsHandler.Get)
notifPrefs.PUT("/:id", notifPrefsHandler.Update)
notifPrefs.DELETE("/:id", notifPrefsHandler.Delete)
notifPrefs.GET("/user/:user_id", notifPrefsHandler.GetByUser)
}
}
}
// Serve admin panel static files
setupStaticFiles(router)
}
// setupStaticFiles configures serving the admin panel static files
func setupStaticFiles(router *gin.Engine) {
// Determine the static files directory
// Check multiple possible locations
possiblePaths := []string{
"admin/out", // Development: from project root
"./admin/out", // Development: relative
"/app/admin/out", // Docker: absolute path
}
var staticDir string
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
staticDir = path
break
}
}
if staticDir == "" {
// Admin panel not built yet, skip static file serving
return
}
// Serve static files at /admin/*
router.GET("/admin/*filepath", func(c *gin.Context) {
filePath := c.Param("filepath")
// Clean the path
filePath = strings.TrimPrefix(filePath, "/")
if filePath == "" {
filePath = "index.html"
}
fullPath := filepath.Join(staticDir, filePath)
// Check if file exists
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// For SPA routing, serve index.html for non-existent paths
// But only if it's not a file request (no extension or .html)
ext := filepath.Ext(filePath)
if ext == "" || ext == ".html" {
// Try to find the specific page's index.html
pagePath := filepath.Join(staticDir, filePath, "index.html")
if _, err := os.Stat(pagePath); err == nil {
c.File(pagePath)
return
}
// Fall back to root index.html
c.File(filepath.Join(staticDir, "index.html"))
return
}
c.Status(http.StatusNotFound)
return
}
// If it's a directory, serve index.html
info, _ := os.Stat(fullPath)
if info.IsDir() {
indexPath := filepath.Join(fullPath, "index.html")
if _, err := os.Stat(indexPath); err == nil {
c.File(indexPath)
return
}
}
c.File(fullPath)
})
}

View File

@@ -1,73 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetContractorsTable returns the contractors table configuration
func GetContractorsTable(ctx *context.Context) table.Table {
contractors := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := contractors.GetInfo()
info.SetTable("task_contractor")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Company", "company", db.Varchar).FieldFilterable()
info.AddField("Phone", "phone", db.Varchar).FieldFilterable()
info.AddField("Email", "email", db.Varchar).FieldFilterable()
info.AddField("Residence ID", "residence_id", db.Int).FieldFilterable()
info.AddField("Specialty ID", "specialty_id", db.Int).FieldFilterable()
info.AddField("Is Favorite", "is_favorite", db.Bool).FieldFilterable()
info.AddField("Notes", "notes", db.Text)
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.AddField("Updated At", "updated_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := contractors.GetForm()
formList.SetTable("task_contractor")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Company", "company", db.Varchar, form.Text)
formList.AddField("Phone", "phone", db.Varchar, form.Text)
formList.AddField("Email", "email", db.Varchar, form.Email)
formList.AddField("Website", "website", db.Varchar, form.Url)
formList.AddField("Address", "address", db.Varchar, form.Text)
formList.AddField("City", "city", db.Varchar, form.Text)
formList.AddField("State", "state", db.Varchar, form.Text)
formList.AddField("Zip Code", "zip_code", db.Varchar, form.Text)
formList.AddField("Residence ID", "residence_id", db.Int, form.Number).FieldMust()
formList.AddField("Specialty ID", "specialty_id", db.Int, form.Number)
formList.AddField("Is Favorite", "is_favorite", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Notes", "notes", db.Text, form.TextArea)
return contractors
}
// GetContractorSpecialtiesTable returns the contractor specialties lookup table configuration
func GetContractorSpecialtiesTable(ctx *context.Context) table.Table {
specialties := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := specialties.GetInfo()
info.SetTable("task_contractorspecialty")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Icon (iOS)", "icon_ios", db.Varchar)
info.AddField("Icon (Android)", "icon_android", db.Varchar)
info.AddField("Display Order", "display_order", db.Int).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := specialties.GetForm()
formList.SetTable("task_contractorspecialty")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Icon (iOS)", "icon_ios", db.Varchar, form.Text)
formList.AddField("Icon (Android)", "icon_android", db.Varchar, form.Text)
formList.AddField("Display Order", "display_order", db.Int, form.Number).FieldDefault("0")
return specialties
}

View File

@@ -1,57 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetDocumentsTable returns the documents table configuration
func GetDocumentsTable(ctx *context.Context) table.Table {
documents := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := documents.GetInfo()
info.SetTable("task_document")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Title", "title", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Type", "document_type", db.Varchar).FieldFilterable()
info.AddField("Residence ID", "residence_id", db.Int).FieldFilterable()
info.AddField("File URL", "file_url", db.Varchar)
info.AddField("Is Active", "is_active", db.Bool).FieldFilterable()
info.AddField("Expiration Date", "expiration_date", db.Date).FieldSortable()
info.AddField("Created By ID", "created_by_id", db.Int).FieldFilterable()
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.AddField("Updated At", "updated_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := documents.GetForm()
formList.SetTable("task_document")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Title", "title", db.Varchar, form.Text).FieldMust()
formList.AddField("Description", "description", db.Text, form.TextArea)
formList.AddField("Type", "document_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Value: "warranty", Text: "Warranty"},
{Value: "contract", Text: "Contract"},
{Value: "receipt", Text: "Receipt"},
{Value: "manual", Text: "Manual"},
{Value: "insurance", Text: "Insurance"},
{Value: "other", Text: "Other"},
})
formList.AddField("Residence ID", "residence_id", db.Int, form.Number).FieldMust()
formList.AddField("File URL", "file_url", db.Varchar, form.Url)
formList.AddField("Is Active", "is_active", db.Bool, form.Switch).FieldDefault("true")
formList.AddField("Expiration Date", "expiration_date", db.Date, form.Date)
formList.AddField("Purchase Date", "purchase_date", db.Date, form.Date)
formList.AddField("Purchase Amount", "purchase_amount", db.Decimal, form.Currency)
formList.AddField("Vendor", "vendor", db.Varchar, form.Text)
formList.AddField("Serial Number", "serial_number", db.Varchar, form.Text)
formList.AddField("Model Number", "model_number", db.Varchar, form.Text)
formList.AddField("Created By ID", "created_by_id", db.Int, form.Number)
formList.AddField("Notes", "notes", db.Text, form.TextArea)
return documents
}

View File

@@ -1,51 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetNotificationsTable returns the notifications table configuration
func GetNotificationsTable(ctx *context.Context) table.Table {
notifications := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := notifications.GetInfo()
info.SetTable("notifications_notification")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("User ID", "user_id", db.Int).FieldFilterable()
info.AddField("Title", "title", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Message", "message", db.Text)
info.AddField("Type", "notification_type", db.Varchar).FieldFilterable()
info.AddField("Is Read", "is_read", db.Bool).FieldFilterable()
info.AddField("Task ID", "task_id", db.Int).FieldFilterable()
info.AddField("Residence ID", "residence_id", db.Int).FieldFilterable()
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := notifications.GetForm()
formList.SetTable("notifications_notification")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("User ID", "user_id", db.Int, form.Number).FieldMust()
formList.AddField("Title", "title", db.Varchar, form.Text).FieldMust()
formList.AddField("Message", "message", db.Text, form.TextArea).FieldMust()
formList.AddField("Type", "notification_type", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Value: "task_assigned", Text: "Task Assigned"},
{Value: "task_completed", Text: "Task Completed"},
{Value: "task_due", Text: "Task Due"},
{Value: "task_overdue", Text: "Task Overdue"},
{Value: "residence_shared", Text: "Residence Shared"},
{Value: "system", Text: "System"},
})
formList.AddField("Is Read", "is_read", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Task ID", "task_id", db.Int, form.Number)
formList.AddField("Residence ID", "residence_id", db.Int, form.Number)
formList.AddField("Data JSON", "data", db.Text, form.TextArea)
formList.AddField("Action URL", "action_url", db.Varchar, form.Url)
return notifications
}

View File

@@ -1,70 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetResidencesTable returns the residences table configuration
func GetResidencesTable(ctx *context.Context) table.Table {
residences := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := residences.GetInfo()
info.SetTable("residence_residence")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Address", "address", db.Varchar).FieldFilterable()
info.AddField("City", "city", db.Varchar).FieldFilterable()
info.AddField("State", "state", db.Varchar).FieldFilterable()
info.AddField("Zip Code", "zip_code", db.Varchar).FieldFilterable()
info.AddField("Owner ID", "owner_id", db.Int).FieldFilterable()
info.AddField("Type ID", "residence_type_id", db.Int).FieldFilterable()
info.AddField("Is Active", "is_active", db.Bool).FieldFilterable()
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.AddField("Updated At", "updated_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := residences.GetForm()
formList.SetTable("residence_residence")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Address", "address", db.Varchar, form.Text)
formList.AddField("City", "city", db.Varchar, form.Text)
formList.AddField("State", "state", db.Varchar, form.Text)
formList.AddField("Zip Code", "zip_code", db.Varchar, form.Text)
formList.AddField("Owner ID", "owner_id", db.Int, form.Number).FieldMust()
formList.AddField("Type ID", "residence_type_id", db.Int, form.Number)
formList.AddField("Is Active", "is_active", db.Bool, form.Switch).FieldDefault("true")
formList.AddField("Share Code", "share_code", db.Varchar, form.Text)
formList.AddField("Share Code Expires", "share_code_expires_at", db.Timestamp, form.Datetime)
return residences
}
// GetResidenceTypesTable returns the residence types lookup table configuration
func GetResidenceTypesTable(ctx *context.Context) table.Table {
types := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := types.GetInfo()
info.SetTable("residence_residencetype")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Icon (iOS)", "icon_ios", db.Varchar)
info.AddField("Icon (Android)", "icon_android", db.Varchar)
info.AddField("Display Order", "display_order", db.Int).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := types.GetForm()
formList.SetTable("residence_residencetype")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Icon (iOS)", "icon_ios", db.Varchar, form.Text)
formList.AddField("Icon (Android)", "icon_android", db.Varchar, form.Text)
formList.AddField("Display Order", "display_order", db.Int, form.Number).FieldDefault("0")
return types
}

View File

@@ -1,53 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetSubscriptionsTable returns the user subscriptions table configuration
func GetSubscriptionsTable(ctx *context.Context) table.Table {
subscriptions := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := subscriptions.GetInfo()
info.SetTable("subscription_usersubscription")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("User ID", "user_id", db.Int).FieldFilterable()
info.AddField("Tier", "tier", db.Varchar).FieldFilterable()
info.AddField("Subscribed At", "subscribed_at", db.Timestamp).FieldSortable()
info.AddField("Expires At", "expires_at", db.Timestamp).FieldSortable()
info.AddField("Cancelled At", "cancelled_at", db.Timestamp).FieldSortable()
info.AddField("Auto Renew", "auto_renew", db.Bool).FieldFilterable()
info.AddField("Platform", "platform", db.Varchar).FieldFilterable()
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.AddField("Updated At", "updated_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := subscriptions.GetForm()
formList.SetTable("subscription_usersubscription")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("User ID", "user_id", db.Int, form.Number).FieldMust()
formList.AddField("Tier", "tier", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Value: "free", Text: "Free"},
{Value: "pro", Text: "Pro"},
}).FieldDefault("free")
formList.AddField("Subscribed At", "subscribed_at", db.Timestamp, form.Datetime)
formList.AddField("Expires At", "expires_at", db.Timestamp, form.Datetime)
formList.AddField("Cancelled At", "cancelled_at", db.Timestamp, form.Datetime)
formList.AddField("Auto Renew", "auto_renew", db.Bool, form.Switch).FieldDefault("true")
formList.AddField("Platform", "platform", db.Varchar, form.SelectSingle).
FieldOptions(types.FieldOptions{
{Value: "", Text: "None"},
{Value: "ios", Text: "iOS"},
{Value: "android", Text: "Android"},
})
formList.AddField("Apple Receipt Data", "apple_receipt_data", db.Text, form.TextArea)
formList.AddField("Google Purchase Token", "google_purchase_token", db.Text, form.TextArea)
return subscriptions
}

View File

@@ -1,21 +0,0 @@
package tables
import "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
// Generators is a map of table generators
var Generators = map[string]table.Generator{
"users": GetUsersTable,
"residences": GetResidencesTable,
"tasks": GetTasksTable,
"task_completions": GetTaskCompletionsTable,
"contractors": GetContractorsTable,
"documents": GetDocumentsTable,
"notifications": GetNotificationsTable,
"user_subscriptions": GetSubscriptionsTable,
"task_categories": GetTaskCategoriesTable,
"task_priorities": GetTaskPrioritiesTable,
"task_statuses": GetTaskStatusesTable,
"task_frequencies": GetTaskFrequenciesTable,
"contractor_specialties": GetContractorSpecialtiesTable,
"residence_types": GetResidenceTypesTable,
}

View File

@@ -1,187 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetTasksTable returns the tasks table configuration
func GetTasksTable(ctx *context.Context) table.Table {
tasks := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := tasks.GetInfo()
info.SetTable("task_task")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Title", "title", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Description", "description", db.Text)
info.AddField("Residence ID", "residence_id", db.Int).FieldFilterable()
info.AddField("Category ID", "category_id", db.Int).FieldFilterable()
info.AddField("Priority ID", "priority_id", db.Int).FieldFilterable()
info.AddField("Status ID", "status_id", db.Int).FieldFilterable()
info.AddField("Frequency ID", "frequency_id", db.Int).FieldFilterable()
info.AddField("Due Date", "due_date", db.Date).FieldFilterable().FieldSortable()
info.AddField("Created By ID", "created_by_id", db.Int).FieldFilterable()
info.AddField("Assigned To ID", "assigned_to_id", db.Int).FieldFilterable()
info.AddField("Is Recurring", "is_recurring", db.Bool).FieldFilterable()
info.AddField("Is Cancelled", "is_cancelled", db.Bool).FieldFilterable()
info.AddField("Is Archived", "is_archived", db.Bool).FieldFilterable()
info.AddField("Estimated Cost", "estimated_cost", db.Decimal)
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.AddField("Updated At", "updated_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := tasks.GetForm()
formList.SetTable("task_task")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Title", "title", db.Varchar, form.Text).FieldMust()
formList.AddField("Description", "description", db.Text, form.TextArea)
formList.AddField("Residence ID", "residence_id", db.Int, form.Number).FieldMust()
formList.AddField("Category ID", "category_id", db.Int, form.Number)
formList.AddField("Priority ID", "priority_id", db.Int, form.Number)
formList.AddField("Status ID", "status_id", db.Int, form.Number)
formList.AddField("Frequency ID", "frequency_id", db.Int, form.Number)
formList.AddField("Due Date", "due_date", db.Date, form.Date)
formList.AddField("Created By ID", "created_by_id", db.Int, form.Number)
formList.AddField("Assigned To ID", "assigned_to_id", db.Int, form.Number)
formList.AddField("Contractor ID", "contractor_id", db.Int, form.Number)
formList.AddField("Is Recurring", "is_recurring", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Is Cancelled", "is_cancelled", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Is Archived", "is_archived", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Estimated Cost", "estimated_cost", db.Decimal, form.Currency)
formList.AddField("Notes", "notes", db.Text, form.TextArea)
return tasks
}
// GetTaskCompletionsTable returns the task completions table configuration
func GetTaskCompletionsTable(ctx *context.Context) table.Table {
completions := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := completions.GetInfo()
info.SetTable("task_taskcompletion")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Task ID", "task_id", db.Int).FieldFilterable()
info.AddField("User ID", "user_id", db.Int).FieldFilterable()
info.AddField("Completed At", "completed_at", db.Timestamp).FieldSortable()
info.AddField("Notes", "notes", db.Text)
info.AddField("Actual Cost", "actual_cost", db.Decimal)
info.AddField("Receipt URL", "receipt_url", db.Varchar)
info.AddField("Created At", "created_at", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := completions.GetForm()
formList.SetTable("task_taskcompletion")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Task ID", "task_id", db.Int, form.Number).FieldMust()
formList.AddField("User ID", "user_id", db.Int, form.Number).FieldMust()
formList.AddField("Completed At", "completed_at", db.Timestamp, form.Datetime)
formList.AddField("Notes", "notes", db.Text, form.TextArea)
formList.AddField("Actual Cost", "actual_cost", db.Decimal, form.Currency)
formList.AddField("Receipt URL", "receipt_url", db.Varchar, form.Url)
return completions
}
// GetTaskCategoriesTable returns the task categories lookup table configuration
func GetTaskCategoriesTable(ctx *context.Context) table.Table {
categories := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := categories.GetInfo()
info.SetTable("task_taskcategory")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Icon (iOS)", "icon_ios", db.Varchar)
info.AddField("Icon (Android)", "icon_android", db.Varchar)
info.AddField("Color", "color", db.Varchar)
info.AddField("Display Order", "display_order", db.Int).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := categories.GetForm()
formList.SetTable("task_taskcategory")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Icon (iOS)", "icon_ios", db.Varchar, form.Text)
formList.AddField("Icon (Android)", "icon_android", db.Varchar, form.Text)
formList.AddField("Color", "color", db.Varchar, form.Color)
formList.AddField("Display Order", "display_order", db.Int, form.Number).FieldDefault("0")
return categories
}
// GetTaskPrioritiesTable returns the task priorities lookup table configuration
func GetTaskPrioritiesTable(ctx *context.Context) table.Table {
priorities := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := priorities.GetInfo()
info.SetTable("task_taskpriority")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Level", "level", db.Int).FieldSortable()
info.AddField("Color", "color", db.Varchar)
info.AddField("Display Order", "display_order", db.Int).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := priorities.GetForm()
formList.SetTable("task_taskpriority")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Level", "level", db.Int, form.Number).FieldMust()
formList.AddField("Color", "color", db.Varchar, form.Color)
formList.AddField("Display Order", "display_order", db.Int, form.Number).FieldDefault("0")
return priorities
}
// GetTaskStatusesTable returns the task statuses lookup table configuration
func GetTaskStatusesTable(ctx *context.Context) table.Table {
statuses := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := statuses.GetInfo()
info.SetTable("task_taskstatus")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Is Terminal", "is_terminal", db.Bool).FieldFilterable()
info.AddField("Color", "color", db.Varchar)
info.AddField("Display Order", "display_order", db.Int).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := statuses.GetForm()
formList.SetTable("task_taskstatus")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Is Terminal", "is_terminal", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Color", "color", db.Varchar, form.Color)
formList.AddField("Display Order", "display_order", db.Int, form.Number).FieldDefault("0")
return statuses
}
// GetTaskFrequenciesTable returns the task frequencies lookup table configuration
func GetTaskFrequenciesTable(ctx *context.Context) table.Table {
frequencies := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := frequencies.GetInfo()
info.SetTable("task_taskfrequency")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Name", "name", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("Days", "days", db.Int).FieldSortable()
info.AddField("Display Order", "display_order", db.Int).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := frequencies.GetForm()
formList.SetTable("task_taskfrequency")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Name", "name", db.Varchar, form.Text).FieldMust()
formList.AddField("Days", "days", db.Int, form.Number)
formList.AddField("Display Order", "display_order", db.Int, form.Number).FieldDefault("0")
return frequencies
}

View File

@@ -1,42 +0,0 @@
package tables
import (
"github.com/GoAdminGroup/go-admin/context"
"github.com/GoAdminGroup/go-admin/modules/db"
"github.com/GoAdminGroup/go-admin/plugins/admin/modules/table"
"github.com/GoAdminGroup/go-admin/template/types/form"
)
// GetUsersTable returns the users table configuration
func GetUsersTable(ctx *context.Context) table.Table {
users := table.NewDefaultTable(ctx, table.DefaultConfigWithDriver(db.DriverPostgresql))
info := users.GetInfo()
info.SetTable("auth_user")
info.AddField("ID", "id", db.Int).FieldFilterable()
info.AddField("Email", "email", db.Varchar).FieldFilterable().FieldSortable()
info.AddField("First Name", "first_name", db.Varchar).FieldFilterable()
info.AddField("Last Name", "last_name", db.Varchar).FieldFilterable()
info.AddField("Is Active", "is_active", db.Bool).FieldFilterable()
info.AddField("Is Staff", "is_staff", db.Bool).FieldFilterable()
info.AddField("Is Superuser", "is_superuser", db.Bool).FieldFilterable()
info.AddField("Email Verified", "email_verified", db.Bool).FieldFilterable()
info.AddField("Date Joined", "date_joined", db.Timestamp).FieldSortable()
info.AddField("Last Login", "last_login", db.Timestamp).FieldSortable()
info.SetFilterFormLayout(form.LayoutThreeCol)
formList := users.GetForm()
formList.SetTable("auth_user")
formList.AddField("ID", "id", db.Int, form.Default).FieldNotAllowAdd().FieldNotAllowEdit()
formList.AddField("Email", "email", db.Varchar, form.Email).FieldMust()
formList.AddField("First Name", "first_name", db.Varchar, form.Text)
formList.AddField("Last Name", "last_name", db.Varchar, form.Text)
formList.AddField("Is Active", "is_active", db.Bool, form.Switch).FieldDefault("true")
formList.AddField("Is Staff", "is_staff", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Is Superuser", "is_superuser", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Email Verified", "email_verified", db.Bool, form.Switch).FieldDefault("false")
formList.AddField("Timezone", "timezone", db.Varchar, form.Text).FieldDefault("UTC")
return users
}