Initial commit: MyCrib API in Go
Complete rewrite of Django REST API to Go with: - Gin web framework for HTTP routing - GORM for database operations - GoAdmin for admin panel - Gorush integration for push notifications - Redis for caching and job queues Features implemented: - User authentication (login, register, logout, password reset) - Residence management (CRUD, sharing, share codes) - Task management (CRUD, kanban board, completions) - Contractor management (CRUD, specialties) - Document management (CRUD, warranties) - Notifications (preferences, push notifications) - Subscription management (tiers, limits) Infrastructure: - Docker Compose for local development - Database migrations and seed data - Admin panel for data management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
38
internal/models/base.go
Normal file
38
internal/models/base.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseModel contains common columns for all tables with ID, CreatedAt, UpdatedAt
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SoftDeleteModel extends BaseModel with soft delete support
|
||||
type SoftDeleteModel struct {
|
||||
BaseModel
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate sets timestamps before creating a record
|
||||
func (b *BaseModel) BeforeCreate(tx *gorm.DB) error {
|
||||
now := time.Now().UTC()
|
||||
if b.CreatedAt.IsZero() {
|
||||
b.CreatedAt = now
|
||||
}
|
||||
if b.UpdatedAt.IsZero() {
|
||||
b.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate sets updated_at before updating a record
|
||||
func (b *BaseModel) BeforeUpdate(tx *gorm.DB) error {
|
||||
b.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
53
internal/models/contractor.go
Normal file
53
internal/models/contractor.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
// ContractorSpecialty represents the task_contractorspecialty table
|
||||
type ContractorSpecialty struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:50;not null" json:"name"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
Icon string `gorm:"column:icon;size:50" json:"icon"`
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ContractorSpecialty) TableName() string {
|
||||
return "task_contractorspecialty"
|
||||
}
|
||||
|
||||
// Contractor represents the task_contractor table
|
||||
type Contractor struct {
|
||||
BaseModel
|
||||
ResidenceID uint `gorm:"column:residence_id;index;not null" json:"residence_id"`
|
||||
Residence Residence `gorm:"foreignKey:ResidenceID" json:"-"`
|
||||
CreatedByID uint `gorm:"column:created_by_id;index;not null" json:"created_by_id"`
|
||||
CreatedBy User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
|
||||
|
||||
Name string `gorm:"column:name;size:200;not null" json:"name"`
|
||||
Company string `gorm:"column:company;size:200" json:"company"`
|
||||
Phone string `gorm:"column:phone;size:20" json:"phone"`
|
||||
Email string `gorm:"column:email;size:254" json:"email"`
|
||||
Website string `gorm:"column:website;size:200" json:"website"`
|
||||
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||
|
||||
// Address
|
||||
StreetAddress string `gorm:"column:street_address;size:255" json:"street_address"`
|
||||
City string `gorm:"column:city;size:100" json:"city"`
|
||||
StateProvince string `gorm:"column:state_province;size:100" json:"state_province"`
|
||||
PostalCode string `gorm:"column:postal_code;size:20" json:"postal_code"`
|
||||
|
||||
// Specialties (many-to-many)
|
||||
Specialties []ContractorSpecialty `gorm:"many2many:task_contractor_specialties;" json:"specialties,omitempty"`
|
||||
|
||||
// Rating and favorites
|
||||
Rating *float64 `gorm:"column:rating;type:decimal(2,1)" json:"rating"`
|
||||
IsFavorite bool `gorm:"column:is_favorite;default:false" json:"is_favorite"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
|
||||
// Tasks associated with this contractor
|
||||
Tasks []Task `gorm:"foreignKey:ContractorID" json:"tasks,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (Contractor) TableName() string {
|
||||
return "task_contractor"
|
||||
}
|
||||
75
internal/models/document.go
Normal file
75
internal/models/document.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// DocumentType represents the type of document
|
||||
type DocumentType string
|
||||
|
||||
const (
|
||||
DocumentTypeGeneral DocumentType = "general"
|
||||
DocumentTypeWarranty DocumentType = "warranty"
|
||||
DocumentTypeReceipt DocumentType = "receipt"
|
||||
DocumentTypeContract DocumentType = "contract"
|
||||
DocumentTypeInsurance DocumentType = "insurance"
|
||||
DocumentTypeManual DocumentType = "manual"
|
||||
)
|
||||
|
||||
// Document represents the task_document table
|
||||
type Document struct {
|
||||
BaseModel
|
||||
ResidenceID uint `gorm:"column:residence_id;index;not null" json:"residence_id"`
|
||||
Residence Residence `gorm:"foreignKey:ResidenceID" json:"-"`
|
||||
CreatedByID uint `gorm:"column:created_by_id;index;not null" json:"created_by_id"`
|
||||
CreatedBy User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
|
||||
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
DocumentType DocumentType `gorm:"column:document_type;size:20;default:'general'" json:"document_type"`
|
||||
|
||||
// File information
|
||||
FileURL string `gorm:"column:file_url;size:500" json:"file_url"`
|
||||
FileName string `gorm:"column:file_name;size:255" json:"file_name"`
|
||||
FileSize *int64 `gorm:"column:file_size" json:"file_size"`
|
||||
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
|
||||
|
||||
// Warranty-specific fields
|
||||
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
|
||||
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
|
||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
||||
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
||||
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
|
||||
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
|
||||
|
||||
// Associated task (optional)
|
||||
TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
|
||||
Task *Task `gorm:"foreignKey:TaskID" json:"task,omitempty"`
|
||||
|
||||
// State
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (Document) TableName() string {
|
||||
return "task_document"
|
||||
}
|
||||
|
||||
// IsWarrantyExpiringSoon returns true if the warranty expires within the specified days
|
||||
func (d *Document) IsWarrantyExpiringSoon(days int) bool {
|
||||
if d.DocumentType != DocumentTypeWarranty || d.ExpiryDate == nil {
|
||||
return false
|
||||
}
|
||||
threshold := time.Now().UTC().AddDate(0, 0, days)
|
||||
return d.ExpiryDate.Before(threshold) && d.ExpiryDate.After(time.Now().UTC())
|
||||
}
|
||||
|
||||
// IsWarrantyExpired returns true if the warranty has expired
|
||||
func (d *Document) IsWarrantyExpired() bool {
|
||||
if d.DocumentType != DocumentTypeWarranty || d.ExpiryDate == nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().After(*d.ExpiryDate)
|
||||
}
|
||||
123
internal/models/notification.go
Normal file
123
internal/models/notification.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NotificationPreference represents the notifications_notificationpreference table
|
||||
type NotificationPreference struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
|
||||
// Task notifications
|
||||
TaskDueSoon bool `gorm:"column:task_due_soon;default:true" json:"task_due_soon"`
|
||||
TaskOverdue bool `gorm:"column:task_overdue;default:true" json:"task_overdue"`
|
||||
TaskCompleted bool `gorm:"column:task_completed;default:true" json:"task_completed"`
|
||||
TaskAssigned bool `gorm:"column:task_assigned;default:true" json:"task_assigned"`
|
||||
|
||||
// Residence notifications
|
||||
ResidenceShared bool `gorm:"column:residence_shared;default:true" json:"residence_shared"`
|
||||
|
||||
// Document notifications
|
||||
WarrantyExpiring bool `gorm:"column:warranty_expiring;default:true" json:"warranty_expiring"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (NotificationPreference) TableName() string {
|
||||
return "notifications_notificationpreference"
|
||||
}
|
||||
|
||||
// NotificationType represents the type of notification
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationTaskDueSoon NotificationType = "task_due_soon"
|
||||
NotificationTaskOverdue NotificationType = "task_overdue"
|
||||
NotificationTaskCompleted NotificationType = "task_completed"
|
||||
NotificationTaskAssigned NotificationType = "task_assigned"
|
||||
NotificationResidenceShared NotificationType = "residence_shared"
|
||||
NotificationWarrantyExpiring NotificationType = "warranty_expiring"
|
||||
)
|
||||
|
||||
// Notification represents the notifications_notification table
|
||||
type Notification struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
NotificationType NotificationType `gorm:"column:notification_type;size:50;not null" json:"notification_type"`
|
||||
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Body string `gorm:"column:body;type:text;not null" json:"body"`
|
||||
|
||||
// Related object (optional)
|
||||
TaskID *uint `gorm:"column:task_id" json:"task_id"`
|
||||
// Task *Task `gorm:"foreignKey:TaskID" json:"task,omitempty"` // Uncomment when Task model is implemented
|
||||
|
||||
// Additional data (JSON)
|
||||
Data string `gorm:"column:data;type:jsonb;default:'{}'" json:"data"`
|
||||
|
||||
// Delivery tracking
|
||||
Sent bool `gorm:"column:sent;default:false" json:"sent"`
|
||||
SentAt *time.Time `gorm:"column:sent_at" json:"sent_at"`
|
||||
|
||||
// Read tracking
|
||||
Read bool `gorm:"column:read;default:false" json:"read"`
|
||||
ReadAt *time.Time `gorm:"column:read_at" json:"read_at"`
|
||||
|
||||
// Error handling
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (Notification) TableName() string {
|
||||
return "notifications_notification"
|
||||
}
|
||||
|
||||
// MarkAsRead marks the notification as read
|
||||
func (n *Notification) MarkAsRead() {
|
||||
n.Read = true
|
||||
now := time.Now().UTC()
|
||||
n.ReadAt = &now
|
||||
}
|
||||
|
||||
// MarkAsSent marks the notification as sent
|
||||
func (n *Notification) MarkAsSent() {
|
||||
n.Sent = true
|
||||
now := time.Now().UTC()
|
||||
n.SentAt = &now
|
||||
}
|
||||
|
||||
// APNSDevice represents iOS devices for push notifications
|
||||
type APNSDevice struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;size:255" json:"name"`
|
||||
Active bool `gorm:"column:active;default:true" json:"active"`
|
||||
UserID *uint `gorm:"column:user_id;index" json:"user_id"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
DeviceID string `gorm:"column:device_id;size:255" json:"device_id"`
|
||||
RegistrationID string `gorm:"column:registration_id;uniqueIndex;size:255" json:"registration_id"`
|
||||
DateCreated time.Time `gorm:"column:date_created;autoCreateTime" json:"date_created"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (APNSDevice) TableName() string {
|
||||
return "push_notifications_apnsdevice"
|
||||
}
|
||||
|
||||
// GCMDevice represents Android devices for push notifications
|
||||
type GCMDevice struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;size:255" json:"name"`
|
||||
Active bool `gorm:"column:active;default:true" json:"active"`
|
||||
UserID *uint `gorm:"column:user_id;index" json:"user_id"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
DeviceID string `gorm:"column:device_id;size:255" json:"device_id"`
|
||||
RegistrationID string `gorm:"column:registration_id;uniqueIndex;size:255" json:"registration_id"`
|
||||
CloudMessageType string `gorm:"column:cloud_message_type;size:3;default:'FCM'" json:"cloud_message_type"`
|
||||
DateCreated time.Time `gorm:"column:date_created;autoCreateTime" json:"date_created"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (GCMDevice) TableName() string {
|
||||
return "push_notifications_gcmdevice"
|
||||
}
|
||||
105
internal/models/residence.go
Normal file
105
internal/models/residence.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// ResidenceType represents the residence_residencetype table
|
||||
type ResidenceType struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:20;not null" json:"name"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ResidenceType) TableName() string {
|
||||
return "residence_residencetype"
|
||||
}
|
||||
|
||||
// Residence represents the residence_residence table
|
||||
type Residence struct {
|
||||
BaseModel
|
||||
OwnerID uint `gorm:"column:owner_id;index;not null" json:"owner_id"`
|
||||
Owner User `gorm:"foreignKey:OwnerID" json:"owner,omitempty"`
|
||||
Users []User `gorm:"many2many:residence_residence_users;" json:"users,omitempty"`
|
||||
|
||||
Name string `gorm:"column:name;size:200;not null" json:"name"`
|
||||
PropertyTypeID *uint `gorm:"column:property_type_id" json:"property_type_id"`
|
||||
PropertyType *ResidenceType `gorm:"foreignKey:PropertyTypeID" json:"property_type,omitempty"`
|
||||
|
||||
// Address
|
||||
StreetAddress string `gorm:"column:street_address;size:255" json:"street_address"`
|
||||
ApartmentUnit string `gorm:"column:apartment_unit;size:50" json:"apartment_unit"`
|
||||
City string `gorm:"column:city;size:100" json:"city"`
|
||||
StateProvince string `gorm:"column:state_province;size:100" json:"state_province"`
|
||||
PostalCode string `gorm:"column:postal_code;size:20" json:"postal_code"`
|
||||
Country string `gorm:"column:country;size:100;default:'USA'" json:"country"`
|
||||
|
||||
// Property Details
|
||||
Bedrooms *int `gorm:"column:bedrooms" json:"bedrooms"`
|
||||
Bathrooms *decimal.Decimal `gorm:"column:bathrooms;type:decimal(3,1)" json:"bathrooms"`
|
||||
SquareFootage *int `gorm:"column:square_footage" json:"square_footage"`
|
||||
LotSize *decimal.Decimal `gorm:"column:lot_size;type:decimal(10,2)" json:"lot_size"`
|
||||
YearBuilt *int `gorm:"column:year_built" json:"year_built"`
|
||||
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
|
||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(12,2)" json:"purchase_price"`
|
||||
|
||||
IsPrimary bool `gorm:"column:is_primary;default:true" json:"is_primary"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"` // Soft delete flag
|
||||
|
||||
// Relations (to be implemented in Phase 3)
|
||||
// Tasks []Task `gorm:"foreignKey:ResidenceID" json:"tasks,omitempty"`
|
||||
// Documents []Document `gorm:"foreignKey:ResidenceID" json:"documents,omitempty"`
|
||||
// ShareCodes []ResidenceShareCode `gorm:"foreignKey:ResidenceID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (Residence) TableName() string {
|
||||
return "residence_residence"
|
||||
}
|
||||
|
||||
// GetAllUsers returns all users with access to this residence (owner + shared users)
|
||||
func (r *Residence) GetAllUsers() []User {
|
||||
users := make([]User, 0, len(r.Users)+1)
|
||||
users = append(users, r.Owner)
|
||||
users = append(users, r.Users...)
|
||||
return users
|
||||
}
|
||||
|
||||
// HasAccess checks if a user has access to this residence
|
||||
func (r *Residence) HasAccess(userID uint) bool {
|
||||
if r.OwnerID == userID {
|
||||
return true
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
if u.ID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPrimaryOwner checks if a user is the primary owner
|
||||
func (r *Residence) IsPrimaryOwner(userID uint) bool {
|
||||
return r.OwnerID == userID
|
||||
}
|
||||
|
||||
// ResidenceShareCode represents the residence_residencesharecode table
|
||||
type ResidenceShareCode struct {
|
||||
BaseModel
|
||||
ResidenceID uint `gorm:"column:residence_id;index;not null" json:"residence_id"`
|
||||
Residence Residence `gorm:"foreignKey:ResidenceID" json:"-"`
|
||||
Code string `gorm:"column:code;uniqueIndex;size:6;not null" json:"code"`
|
||||
CreatedByID uint `gorm:"column:created_by_id;not null" json:"created_by_id"`
|
||||
CreatedBy User `gorm:"foreignKey:CreatedByID" json:"-"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ResidenceShareCode) TableName() string {
|
||||
return "residence_residencesharecode"
|
||||
}
|
||||
163
internal/models/subscription.go
Normal file
163
internal/models/subscription.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SubscriptionTier represents the subscription tier
|
||||
type SubscriptionTier string
|
||||
|
||||
const (
|
||||
TierFree SubscriptionTier = "free"
|
||||
TierPro SubscriptionTier = "pro"
|
||||
)
|
||||
|
||||
// SubscriptionSettings represents the subscription_subscriptionsettings table (singleton)
|
||||
type SubscriptionSettings struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
EnableLimitations bool `gorm:"column:enable_limitations;default:false" json:"enable_limitations"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (SubscriptionSettings) TableName() string {
|
||||
return "subscription_subscriptionsettings"
|
||||
}
|
||||
|
||||
// UserSubscription represents the subscription_usersubscription table
|
||||
type UserSubscription struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
Tier SubscriptionTier `gorm:"column:tier;size:10;default:'free'" json:"tier"`
|
||||
|
||||
// In-App Purchase data
|
||||
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
|
||||
GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"`
|
||||
|
||||
// Subscription dates
|
||||
SubscribedAt *time.Time `gorm:"column:subscribed_at" json:"subscribed_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
|
||||
AutoRenew bool `gorm:"column:auto_renew;default:true" json:"auto_renew"`
|
||||
|
||||
// Tracking
|
||||
CancelledAt *time.Time `gorm:"column:cancelled_at" json:"cancelled_at"`
|
||||
Platform string `gorm:"column:platform;size:10" json:"platform"` // ios, android
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (UserSubscription) TableName() string {
|
||||
return "subscription_usersubscription"
|
||||
}
|
||||
|
||||
// IsActive returns true if the subscription is active (pro tier and not expired)
|
||||
func (s *UserSubscription) IsActive() bool {
|
||||
if s.Tier != TierPro {
|
||||
return false
|
||||
}
|
||||
if s.ExpiresAt != nil && time.Now().UTC().After(*s.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsPro returns true if the user has a pro subscription
|
||||
func (s *UserSubscription) IsPro() bool {
|
||||
return s.Tier == TierPro && s.IsActive()
|
||||
}
|
||||
|
||||
// UpgradeTrigger represents the subscription_upgradetrigger table
|
||||
type UpgradeTrigger struct {
|
||||
BaseModel
|
||||
TriggerKey string `gorm:"column:trigger_key;uniqueIndex;size:50;not null" json:"trigger_key"`
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Message string `gorm:"column:message;type:text;not null" json:"message"`
|
||||
PromoHTML string `gorm:"column:promo_html;type:text" json:"promo_html"`
|
||||
ButtonText string `gorm:"column:button_text;size:50;default:'Upgrade to Pro'" json:"button_text"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (UpgradeTrigger) TableName() string {
|
||||
return "subscription_upgradetrigger"
|
||||
}
|
||||
|
||||
// FeatureBenefit represents the subscription_featurebenefit table
|
||||
type FeatureBenefit struct {
|
||||
BaseModel
|
||||
FeatureName string `gorm:"column:feature_name;size:200;not null" json:"feature_name"`
|
||||
FreeTierText string `gorm:"column:free_tier_text;size:200;not null" json:"free_tier_text"`
|
||||
ProTierText string `gorm:"column:pro_tier_text;size:200;not null" json:"pro_tier_text"`
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (FeatureBenefit) TableName() string {
|
||||
return "subscription_featurebenefit"
|
||||
}
|
||||
|
||||
// Promotion represents the subscription_promotion table
|
||||
type Promotion struct {
|
||||
BaseModel
|
||||
PromotionID string `gorm:"column:promotion_id;uniqueIndex;size:50;not null" json:"promotion_id"`
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Message string `gorm:"column:message;type:text;not null" json:"message"`
|
||||
Link *string `gorm:"column:link;size:200" json:"link"`
|
||||
StartDate time.Time `gorm:"column:start_date;not null" json:"start_date"`
|
||||
EndDate time.Time `gorm:"column:end_date;not null" json:"end_date"`
|
||||
TargetTier SubscriptionTier `gorm:"column:target_tier;size:10;default:'free'" json:"target_tier"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (Promotion) TableName() string {
|
||||
return "subscription_promotion"
|
||||
}
|
||||
|
||||
// IsCurrentlyActive returns true if the promotion is currently active
|
||||
func (p *Promotion) IsCurrentlyActive() bool {
|
||||
if !p.IsActive {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return now.After(p.StartDate) && now.Before(p.EndDate)
|
||||
}
|
||||
|
||||
// TierLimits represents the subscription_tierlimits table
|
||||
type TierLimits struct {
|
||||
BaseModel
|
||||
Tier SubscriptionTier `gorm:"column:tier;uniqueIndex;size:10;not null" json:"tier"`
|
||||
PropertiesLimit *int `gorm:"column:properties_limit" json:"properties_limit"`
|
||||
TasksLimit *int `gorm:"column:tasks_limit" json:"tasks_limit"`
|
||||
ContractorsLimit *int `gorm:"column:contractors_limit" json:"contractors_limit"`
|
||||
DocumentsLimit *int `gorm:"column:documents_limit" json:"documents_limit"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TierLimits) TableName() string {
|
||||
return "subscription_tierlimits"
|
||||
}
|
||||
|
||||
// GetDefaultFreeLimits returns the default limits for the free tier
|
||||
func GetDefaultFreeLimits() TierLimits {
|
||||
one := 1
|
||||
ten := 10
|
||||
zero := 0
|
||||
return TierLimits{
|
||||
Tier: TierFree,
|
||||
PropertiesLimit: &one,
|
||||
TasksLimit: &ten,
|
||||
ContractorsLimit: &zero,
|
||||
DocumentsLimit: &zero,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultProLimits returns the default limits for the pro tier (unlimited)
|
||||
func GetDefaultProLimits() TierLimits {
|
||||
return TierLimits{
|
||||
Tier: TierPro,
|
||||
PropertiesLimit: nil, // nil = unlimited
|
||||
TasksLimit: nil,
|
||||
ContractorsLimit: nil,
|
||||
DocumentsLimit: nil,
|
||||
}
|
||||
}
|
||||
170
internal/models/task.go
Normal file
170
internal/models/task.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TaskCategory represents the task_taskcategory table
|
||||
type TaskCategory struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:50;not null" json:"name"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
Icon string `gorm:"column:icon;size:50" json:"icon"`
|
||||
Color string `gorm:"column:color;size:7" json:"color"` // Hex color
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskCategory) TableName() string {
|
||||
return "task_taskcategory"
|
||||
}
|
||||
|
||||
// TaskPriority represents the task_taskpriority table
|
||||
type TaskPriority struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:20;not null" json:"name"`
|
||||
Level int `gorm:"column:level;not null" json:"level"` // 1=low, 2=medium, 3=high, 4=urgent
|
||||
Color string `gorm:"column:color;size:7" json:"color"`
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskPriority) TableName() string {
|
||||
return "task_taskpriority"
|
||||
}
|
||||
|
||||
// TaskStatus represents the task_taskstatus table
|
||||
type TaskStatus struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:20;not null" json:"name"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
Color string `gorm:"column:color;size:7" json:"color"`
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskStatus) TableName() string {
|
||||
return "task_taskstatus"
|
||||
}
|
||||
|
||||
// TaskFrequency represents the task_taskfrequency table
|
||||
type TaskFrequency struct {
|
||||
BaseModel
|
||||
Name string `gorm:"column:name;size:20;not null" json:"name"`
|
||||
Days *int `gorm:"column:days" json:"days"` // Number of days between occurrences (nil = one-time)
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskFrequency) TableName() string {
|
||||
return "task_taskfrequency"
|
||||
}
|
||||
|
||||
// Task represents the task_task table
|
||||
type Task struct {
|
||||
BaseModel
|
||||
ResidenceID uint `gorm:"column:residence_id;index;not null" json:"residence_id"`
|
||||
Residence Residence `gorm:"foreignKey:ResidenceID" json:"residence,omitempty"`
|
||||
CreatedByID uint `gorm:"column:created_by_id;index;not null" json:"created_by_id"`
|
||||
CreatedBy User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
|
||||
AssignedToID *uint `gorm:"column:assigned_to_id;index" json:"assigned_to_id"`
|
||||
AssignedTo *User `gorm:"foreignKey:AssignedToID" json:"assigned_to,omitempty"`
|
||||
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
|
||||
CategoryID *uint `gorm:"column:category_id;index" json:"category_id"`
|
||||
Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
PriorityID *uint `gorm:"column:priority_id;index" json:"priority_id"`
|
||||
Priority *TaskPriority `gorm:"foreignKey:PriorityID" json:"priority,omitempty"`
|
||||
StatusID *uint `gorm:"column:status_id;index" json:"status_id"`
|
||||
Status *TaskStatus `gorm:"foreignKey:StatusID" json:"status,omitempty"`
|
||||
FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"`
|
||||
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
|
||||
|
||||
DueDate *time.Time `gorm:"column:due_date;type:date;index" json:"due_date"`
|
||||
EstimatedCost *decimal.Decimal `gorm:"column:estimated_cost;type:decimal(10,2)" json:"estimated_cost"`
|
||||
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
|
||||
|
||||
// Contractor association
|
||||
ContractorID *uint `gorm:"column:contractor_id;index" json:"contractor_id"`
|
||||
// Contractor *Contractor `gorm:"foreignKey:ContractorID" json:"contractor,omitempty"`
|
||||
|
||||
// State flags
|
||||
IsCancelled bool `gorm:"column:is_cancelled;default:false;index" json:"is_cancelled"`
|
||||
IsArchived bool `gorm:"column:is_archived;default:false;index" json:"is_archived"`
|
||||
|
||||
// Parent task for recurring tasks
|
||||
ParentTaskID *uint `gorm:"column:parent_task_id;index" json:"parent_task_id"`
|
||||
ParentTask *Task `gorm:"foreignKey:ParentTaskID" json:"parent_task,omitempty"`
|
||||
|
||||
// Completions
|
||||
Completions []TaskCompletion `gorm:"foreignKey:TaskID" json:"completions,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (Task) TableName() string {
|
||||
return "task_task"
|
||||
}
|
||||
|
||||
// IsOverdue returns true if the task is past its due date and not completed
|
||||
func (t *Task) IsOverdue() bool {
|
||||
if t.DueDate == nil || t.IsCancelled || t.IsArchived {
|
||||
return false
|
||||
}
|
||||
// Check if there's a completion
|
||||
if len(t.Completions) > 0 {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().After(*t.DueDate)
|
||||
}
|
||||
|
||||
// IsDueSoon returns true if the task is due within the specified days
|
||||
func (t *Task) IsDueSoon(days int) bool {
|
||||
if t.DueDate == nil || t.IsCancelled || t.IsArchived {
|
||||
return false
|
||||
}
|
||||
if len(t.Completions) > 0 {
|
||||
return false
|
||||
}
|
||||
threshold := time.Now().UTC().AddDate(0, 0, days)
|
||||
return t.DueDate.Before(threshold) && !t.IsOverdue()
|
||||
}
|
||||
|
||||
// TaskCompletion represents the task_taskcompletion table
|
||||
type TaskCompletion struct {
|
||||
BaseModel
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"task_id"`
|
||||
Task Task `gorm:"foreignKey:TaskID" json:"-"`
|
||||
CompletedByID uint `gorm:"column:completed_by_id;index;not null" json:"completed_by_id"`
|
||||
CompletedBy User `gorm:"foreignKey:CompletedByID" json:"completed_by,omitempty"`
|
||||
CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"`
|
||||
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
|
||||
PhotoURL string `gorm:"column:photo_url;size:500" json:"photo_url"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskCompletion) TableName() string {
|
||||
return "task_taskcompletion"
|
||||
}
|
||||
|
||||
// KanbanColumn represents a column in the kanban board
|
||||
type KanbanColumn struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ButtonTypes []string `json:"button_types"`
|
||||
Icons map[string]string `json:"icons"`
|
||||
Color string `json:"color"`
|
||||
Tasks []Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// KanbanBoard represents the full kanban board response
|
||||
type KanbanBoard struct {
|
||||
Columns []KanbanColumn `json:"columns"`
|
||||
DaysThreshold int `json:"days_threshold"`
|
||||
ResidenceID string `json:"residence_id"`
|
||||
}
|
||||
232
internal/models/user.go
Normal file
232
internal/models/user.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents the auth_user table (Django's default User model)
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Password string `gorm:"column:password;size:128;not null" json:"-"`
|
||||
LastLogin *time.Time `gorm:"column:last_login" json:"last_login,omitempty"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
|
||||
Username string `gorm:"column:username;uniqueIndex;size:150;not null" json:"username"`
|
||||
FirstName string `gorm:"column:first_name;size:150" json:"first_name"`
|
||||
LastName string `gorm:"column:last_name;size:150" json:"last_name"`
|
||||
Email string `gorm:"column:email;size:254" json:"email"`
|
||||
IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
DateJoined time.Time `gorm:"column:date_joined;autoCreateTime" json:"date_joined"`
|
||||
|
||||
// Relations (not stored in auth_user table)
|
||||
Profile *UserProfile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
|
||||
AuthToken *AuthToken `gorm:"foreignKey:UserID" json:"-"`
|
||||
OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"`
|
||||
SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"`
|
||||
NotificationPref *NotificationPreference `gorm:"foreignKey:UserID" json:"-"`
|
||||
Subscription *UserSubscription `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (User) TableName() string {
|
||||
return "auth_user"
|
||||
}
|
||||
|
||||
// SetPassword hashes and sets the password
|
||||
func (u *User) SetPassword(password string) error {
|
||||
// Django uses PBKDF2_SHA256 by default, but we'll use bcrypt for Go
|
||||
// Note: This means passwords set by Django won't work with Go's check
|
||||
// For migration, you'd need to either:
|
||||
// 1. Force password reset for all users
|
||||
// 2. Implement Django's PBKDF2 hasher in Go
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against the stored hash
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFullName returns the user's full name
|
||||
func (u *User) GetFullName() string {
|
||||
if u.FirstName != "" && u.LastName != "" {
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
if u.FirstName != "" {
|
||||
return u.FirstName
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// AuthToken represents the user_authtoken table
|
||||
type AuthToken struct {
|
||||
Key string `gorm:"column:key;primaryKey;size:40" json:"key"`
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (AuthToken) TableName() string {
|
||||
return "user_authtoken"
|
||||
}
|
||||
|
||||
// BeforeCreate generates a token key if not provided
|
||||
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.Key == "" {
|
||||
t.Key = generateToken()
|
||||
}
|
||||
if t.Created.IsZero() {
|
||||
t.Created = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateToken creates a random 40-character hex token
|
||||
func generateToken() string {
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GetOrCreate gets an existing token or creates a new one for the user
|
||||
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
|
||||
var token AuthToken
|
||||
result := tx.Where("user_id = ?", userID).First(&token)
|
||||
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
token = AuthToken{UserID: userID}
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// UserProfile represents the user_userprofile table
|
||||
type UserProfile struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
Verified bool `gorm:"column:verified;default:false" json:"verified"`
|
||||
Bio string `gorm:"column:bio;type:text" json:"bio"`
|
||||
PhoneNumber string `gorm:"column:phone_number;size:15" json:"phone_number"`
|
||||
DateOfBirth *time.Time `gorm:"column:date_of_birth;type:date" json:"date_of_birth,omitempty"`
|
||||
ProfilePicture string `gorm:"column:profile_picture;size:100" json:"profile_picture"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (UserProfile) TableName() string {
|
||||
return "user_userprofile"
|
||||
}
|
||||
|
||||
// ConfirmationCode represents the user_confirmationcode table
|
||||
type ConfirmationCode struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
Code string `gorm:"column:code;size:6;not null" json:"code"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
||||
IsUsed bool `gorm:"column:is_used;default:false" json:"is_used"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ConfirmationCode) TableName() string {
|
||||
return "user_confirmationcode"
|
||||
}
|
||||
|
||||
// IsValid checks if the confirmation code is still valid
|
||||
func (c *ConfirmationCode) IsValid() bool {
|
||||
return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt)
|
||||
}
|
||||
|
||||
// GenerateCode creates a random 6-digit code
|
||||
func GenerateConfirmationCode() string {
|
||||
b := make([]byte, 3)
|
||||
rand.Read(b)
|
||||
// Convert to 6-digit number
|
||||
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
|
||||
return string(rune('0'+num%10)) + string(rune('0'+(num/10)%10)) +
|
||||
string(rune('0'+(num/100)%10)) + string(rune('0'+(num/1000)%10)) +
|
||||
string(rune('0'+(num/10000)%10)) + string(rune('0'+(num/100000)%10))
|
||||
}
|
||||
|
||||
// PasswordResetCode represents the user_passwordresetcode table
|
||||
type PasswordResetCode struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
CodeHash string `gorm:"column:code_hash;size:128;not null" json:"-"`
|
||||
ResetToken string `gorm:"column:reset_token;uniqueIndex;size:64;not null" json:"reset_token"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
||||
Used bool `gorm:"column:used;default:false" json:"used"`
|
||||
Attempts int `gorm:"column:attempts;default:0" json:"attempts"`
|
||||
MaxAttempts int `gorm:"column:max_attempts;default:5" json:"max_attempts"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (PasswordResetCode) TableName() string {
|
||||
return "user_passwordresetcode"
|
||||
}
|
||||
|
||||
// SetCode hashes and stores the reset code
|
||||
func (p *PasswordResetCode) SetCode(code string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.CodeHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckCode verifies a code against the stored hash
|
||||
func (p *PasswordResetCode) CheckCode(code string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(p.CodeHash), []byte(code))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsValid checks if the reset code is still valid
|
||||
func (p *PasswordResetCode) IsValid() bool {
|
||||
return !p.Used && time.Now().UTC().Before(p.ExpiresAt) && p.Attempts < p.MaxAttempts
|
||||
}
|
||||
|
||||
// IncrementAttempts increments the attempt counter
|
||||
func (p *PasswordResetCode) IncrementAttempts(tx *gorm.DB) error {
|
||||
p.Attempts++
|
||||
return tx.Model(p).Update("attempts", p.Attempts).Error
|
||||
}
|
||||
|
||||
// MarkAsUsed marks the code as used
|
||||
func (p *PasswordResetCode) MarkAsUsed(tx *gorm.DB) error {
|
||||
p.Used = true
|
||||
return tx.Model(p).Update("used", true).Error
|
||||
}
|
||||
|
||||
// GenerateResetToken creates a URL-safe token
|
||||
func GenerateResetToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user