diff --git a/core/repository/gorm_repository.go b/core/repository/gorm_repository.go new file mode 100644 index 0000000..0fe9364 --- /dev/null +++ b/core/repository/gorm_repository.go @@ -0,0 +1,375 @@ +package repository + +import ( + "errors" + "strings" + "time" + + "github.com/openaccounting/oa-server/core/model/types" + "github.com/openaccounting/oa-server/core/util" + "github.com/openaccounting/oa-server/models" + "gorm.io/gorm" +) + +// GormRepository implements the same interfaces as core/model/db but uses GORM +type GormRepository struct { + db *gorm.DB +} + +// Note: GormRepository implements most of the Datastore interface +// Some methods like DeleteAndInsertTransaction need to be added for full compatibility + +// NewGormRepository creates a new GORM repository +func NewGormRepository(db *gorm.DB) *GormRepository { + return &GormRepository{db: db} +} + +// UserInterface implementation +func (r *GormRepository) InsertUser(user *types.User) error { + user.Inserted = time.Now() + user.Updated = user.Inserted + user.PasswordReset = "" + + // Convert types.User to models.User + gormUser := &models.User{ + ID: []byte(user.Id), // Convert string ID to []byte + Inserted: uint64(util.TimeToMs(user.Inserted)), + Updated: uint64(util.TimeToMs(user.Updated)), + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PasswordHash: user.PasswordHash, + AgreeToTerms: user.AgreeToTerms, + PasswordReset: user.PasswordReset, + EmailVerified: user.EmailVerified, + EmailVerifyCode: user.EmailVerifyCode, + SignupSource: user.SignupSource, + } + + result := r.db.Create(gormUser) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected < 1 { + return errors.New("unable to insert user into db") + } + + return nil +} + +func (r *GormRepository) VerifyUser(code string) error { + result := r.db.Model(&models.User{}). + Where("email_verify_code = ?", code). + Updates(map[string]interface{}{ + "updated": util.TimeToMs(time.Now()), + "email_verified": true, + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return errors.New("invalid code") + } + + return nil +} + +func (r *GormRepository) UpdateUser(user *types.User) error { + user.Updated = time.Now() + + result := r.db.Model(&models.User{}). + Where("id = ?", []byte(user.Id)). + Updates(map[string]interface{}{ + "updated": util.TimeToMs(user.Updated), + "password_hash": user.PasswordHash, + "password_reset": "", + }) + + return result.Error +} + +func (r *GormRepository) UpdateUserResetPassword(user *types.User) error { + user.Updated = time.Now() + + result := r.db.Model(&models.User{}). + Where("id = ?", []byte(user.Id)). + Updates(map[string]interface{}{ + "updated": util.TimeToMs(user.Updated), + "password_reset": user.PasswordReset, + }) + + return result.Error +} + +func (r *GormRepository) GetVerifiedUserByEmail(email string) (*types.User, error) { + var gormUser models.User + result := r.db.Where("email = ? AND email_verified = ?", + strings.TrimSpace(strings.ToLower(email)), true). + First(&gormUser) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormUserToTypesUser(&gormUser), nil +} + +func (r *GormRepository) GetUserByActiveSession(sessionId string) (*types.User, error) { + var gormUser models.User + result := r.db.Table("users"). + Select("users.*"). + Joins("JOIN sessions ON sessions.user_id = users.id"). + Where("sessions.terminated IS NULL AND sessions.id = ?", []byte(sessionId)). + First(&gormUser) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormUserToTypesUser(&gormUser), nil +} + +func (r *GormRepository) GetUserByApiKey(keyId string) (*types.User, error) { + var gormUser models.User + result := r.db.Table("users"). + Select("users.*"). + Joins("JOIN api_keys ON api_keys.user_id = users.id"). + Where("api_keys.deleted_at IS NULL AND api_keys.id = ?", []byte(keyId)). + First(&gormUser) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormUserToTypesUser(&gormUser), nil +} + +func (r *GormRepository) GetUserByResetCode(code string) (*types.User, error) { + var gormUser models.User + result := r.db.Where("password_reset = ?", code).First(&gormUser) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormUserToTypesUser(&gormUser), nil +} + +func (r *GormRepository) GetUserByEmailVerifyCode(code string) (*types.User, error) { + // only allow this for 3 days + minInserted := (time.Now().UnixNano() / 1000000) - (3 * 24 * 60 * 60 * 1000) + + var gormUser models.User + result := r.db.Where("email_verify_code = ? AND inserted > ?", code, minInserted). + First(&gormUser) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormUserToTypesUser(&gormUser), nil +} + +func (r *GormRepository) GetOrgAdmins(orgId string) ([]*types.User, error) { + var gormUsers []models.User + result := r.db.Table("users"). + Select("users.*"). + Joins("JOIN user_orgs ON user_orgs.user_id = users.id"). + Where("user_orgs.admin = ? AND user_orgs.org_id = ?", true, []byte(orgId)). + Find(&gormUsers) + + if result.Error != nil { + return nil, result.Error + } + + users := make([]*types.User, len(gormUsers)) + for i, gormUser := range gormUsers { + users[i] = r.convertGormUserToTypesUser(&gormUser) + } + + return users, nil +} + +// Helper function to convert GORM User to types.User +func (r *GormRepository) convertGormUserToTypesUser(gormUser *models.User) *types.User { + return &types.User{ + Id: string(gormUser.ID), + Inserted: util.MsToTime(int64(gormUser.Inserted)), + Updated: util.MsToTime(int64(gormUser.Updated)), + FirstName: gormUser.FirstName, + LastName: gormUser.LastName, + Email: gormUser.Email, + PasswordHash: gormUser.PasswordHash, + AgreeToTerms: gormUser.AgreeToTerms, + PasswordReset: gormUser.PasswordReset, + EmailVerified: gormUser.EmailVerified, + EmailVerifyCode: gormUser.EmailVerifyCode, + SignupSource: gormUser.SignupSource, + } +} + +// AccountInterface implementation +func (r *GormRepository) InsertAccount(account *types.Account) error { + account.Inserted = time.Now() + account.Updated = account.Inserted + + // Convert types.Account to models.Account + gormAccount := &models.Account{ + ID: []byte(account.Id), + OrgID: []byte(account.OrgId), + Inserted: uint64(util.TimeToMs(account.Inserted)), + Updated: uint64(util.TimeToMs(account.Updated)), + Name: account.Name, + Parent: []byte(account.Parent), + Currency: account.Currency, + Precision: account.Precision, + DebitBalance: account.DebitBalance, + } + + return r.db.Create(gormAccount).Error +} + +func (r *GormRepository) UpdateAccount(account *types.Account) error { + account.Updated = time.Now() + + result := r.db.Model(&models.Account{}). + Where("id = ?", []byte(account.Id)). + Updates(map[string]interface{}{ + "updated": util.TimeToMs(account.Updated), + "name": account.Name, + "parent": []byte(account.Parent), + "currency": account.Currency, + "precision": account.Precision, + "debit_balance": account.DebitBalance, + }) + + return result.Error +} + +func (r *GormRepository) GetAccount(id string) (*types.Account, error) { + var gormAccount models.Account + result := r.db.Where("id = ?", []byte(id)).First(&gormAccount) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormAccountToTypesAccount(&gormAccount), nil +} + +func (r *GormRepository) GetAccountsByOrgId(orgId string) ([]*types.Account, error) { + var gormAccounts []models.Account + result := r.db.Where("org_id = ?", []byte(orgId)).Find(&gormAccounts) + + if result.Error != nil { + return nil, result.Error + } + + accounts := make([]*types.Account, len(gormAccounts)) + for i, gormAccount := range gormAccounts { + accounts[i] = r.convertGormAccountToTypesAccount(&gormAccount) + } + + return accounts, nil +} + +func (r *GormRepository) GetPermissionedAccountIds(orgId, userId, tokenId string) ([]string, error) { + var accountIds []string + result := r.db.Table("permissions"). + Select("DISTINCT LOWER(HEX(account_id)) as account_id"). + Where("org_id = ? AND user_id = ?", []byte(orgId), []byte(userId)). + Pluck("account_id", &accountIds) + + return accountIds, result.Error +} + +func (r *GormRepository) GetSplitCountByAccountId(id string) (int64, error) { + var count int64 + result := r.db.Model(&models.Split{}). + Where("account_id = ?", []byte(id)). + Count(&count) + + return count, result.Error +} + +func (r *GormRepository) GetChildCountByAccountId(id string) (int64, error) { + var count int64 + result := r.db.Model(&models.Account{}). + Where("parent = ?", []byte(id)). + Count(&count) + + return count, result.Error +} + +func (r *GormRepository) DeleteAccount(id string) error { + return r.db.Where("id = ?", []byte(id)).Delete(&models.Account{}).Error +} + +func (r *GormRepository) GetRootAccount(orgId string) (*types.Account, error) { + var gormAccount models.Account + result := r.db.Where("org_id = ? AND parent = ?", []byte(orgId), []byte{0}). + First(&gormAccount) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormAccountToTypesAccount(&gormAccount), nil +} + +// Balance-related methods (simplified implementations) +func (r *GormRepository) AddBalances(accounts []*types.Account, date time.Time) error { + // Implementation would need to be completed based on your balance calculation logic + return nil +} + +func (r *GormRepository) AddNativeBalancesCost(accounts []*types.Account, date time.Time) error { + // Implementation would need to be completed based on your balance calculation logic + return nil +} + +func (r *GormRepository) AddNativeBalancesNearestInTime(accounts []*types.Account, date time.Time) error { + // Implementation would need to be completed based on your balance calculation logic + return nil +} + +func (r *GormRepository) AddBalance(account *types.Account, date time.Time) error { + // Implementation would need to be completed based on your balance calculation logic + return nil +} + +func (r *GormRepository) AddNativeBalanceCost(account *types.Account, date time.Time) error { + // Implementation would need to be completed based on your balance calculation logic + return nil +} + +func (r *GormRepository) AddNativeBalanceNearestInTime(account *types.Account, date time.Time) error { + // Implementation would need to be completed based on your balance calculation logic + return nil +} + +// Helper function to convert GORM Account to types.Account +func (r *GormRepository) convertGormAccountToTypesAccount(gormAccount *models.Account) *types.Account { + return &types.Account{ + Id: string(gormAccount.ID), + OrgId: string(gormAccount.OrgID), + Inserted: util.MsToTime(int64(gormAccount.Inserted)), + Updated: util.MsToTime(int64(gormAccount.Updated)), + Name: gormAccount.Name, + Parent: string(gormAccount.Parent), + Currency: gormAccount.Currency, + Precision: gormAccount.Precision, + DebitBalance: gormAccount.DebitBalance, + // Balance fields would be populated by the AddBalance methods + } +} + +// Escape method for SQL injection protection (GORM handles this automatically) +func (r *GormRepository) Escape(sql string) string { + // GORM handles SQL injection protection automatically + // This method is kept for interface compatibility + return sql +} \ No newline at end of file diff --git a/core/repository/gorm_repository_interfaces.go b/core/repository/gorm_repository_interfaces.go new file mode 100644 index 0000000..50fb15f --- /dev/null +++ b/core/repository/gorm_repository_interfaces.go @@ -0,0 +1,462 @@ +package repository + +import ( + "time" + + "github.com/openaccounting/oa-server/core/model/types" + "github.com/openaccounting/oa-server/core/util" + "github.com/openaccounting/oa-server/models" + "gorm.io/gorm" +) + +// OrgInterface implementation +func (r *GormRepository) CreateOrg(org *types.Org, userId string, accounts []*types.Account) error { + return r.db.Transaction(func(tx *gorm.DB) error { + org.Inserted = time.Now() + org.Updated = org.Inserted + + // Create org + gormOrg := &models.Org{ + ID: []byte(org.Id), + Inserted: uint64(util.TimeToMs(org.Inserted)), + Updated: uint64(util.TimeToMs(org.Updated)), + Name: org.Name, + Currency: org.Currency, + Precision: org.Precision, + Timezone: org.Timezone, + } + + if err := tx.Create(gormOrg).Error; err != nil { + return err + } + + // Create accounts + for _, account := range accounts { + gormAccount := &models.Account{ + ID: []byte(account.Id), + OrgID: []byte(account.OrgId), + Inserted: uint64(util.TimeToMs(time.Now())), + Updated: uint64(util.TimeToMs(time.Now())), + Name: account.Name, + Parent: []byte(account.Parent), + Currency: account.Currency, + Precision: account.Precision, + DebitBalance: account.DebitBalance, + } + if err := tx.Create(gormAccount).Error; err != nil { + return err + } + } + + // Create userorg association + userOrg := &models.UserOrg{ + UserID: []byte(userId), + OrgID: []byte(org.Id), + Admin: true, + } + + return tx.Create(userOrg).Error + }) +} + +func (r *GormRepository) UpdateOrg(org *types.Org) error { + org.Updated = time.Now() + + return r.db.Model(&models.Org{}). + Where("id = ?", []byte(org.Id)). + Updates(map[string]interface{}{ + "updated": util.TimeToMs(org.Updated), + "name": org.Name, + "currency": org.Currency, + "precision": org.Precision, + "timezone": org.Timezone, + }).Error +} + +func (r *GormRepository) GetOrg(orgId, userId string) (*types.Org, error) { + var gormOrg models.Org + result := r.db.Table("orgs"). + Select("orgs.*"). + Joins("JOIN user_orgs ON user_orgs.org_id = orgs.id"). + Where("orgs.id = ? AND user_orgs.user_id = ?", []byte(orgId), []byte(userId)). + First(&gormOrg) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormOrgToTypesOrg(&gormOrg), nil +} + +func (r *GormRepository) GetOrgs(userId string) ([]*types.Org, error) { + var gormOrgs []models.Org + result := r.db.Table("orgs"). + Select("orgs.*"). + Joins("JOIN user_orgs ON user_orgs.org_id = orgs.id"). + Where("user_orgs.user_id = ?", []byte(userId)). + Find(&gormOrgs) + + if result.Error != nil { + return nil, result.Error + } + + orgs := make([]*types.Org, len(gormOrgs)) + for i, gormOrg := range gormOrgs { + orgs[i] = r.convertGormOrgToTypesOrg(&gormOrg) + } + + return orgs, nil +} + +func (r *GormRepository) GetOrgUserIds(orgId string) ([]string, error) { + var userIds []string + result := r.db.Table("user_orgs"). + Select("LOWER(HEX(user_id)) as user_id"). + Where("org_id = ?", []byte(orgId)). + Pluck("user_id", &userIds) + + return userIds, result.Error +} + +func (r *GormRepository) InsertInvite(invite *types.Invite) error { + invite.Inserted = time.Now() + invite.Updated = invite.Inserted + + gormInvite := &models.Invite{ + ID: invite.Id, + OrgID: []byte(invite.OrgId), + Inserted: uint64(util.TimeToMs(invite.Inserted)), + Updated: uint64(util.TimeToMs(invite.Updated)), + Email: invite.Email, + Accepted: invite.Accepted, + } + + return r.db.Create(gormInvite).Error +} + +func (r *GormRepository) AcceptInvite(invite *types.Invite, userId string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Update invite + if err := tx.Model(&models.Invite{}). + Where("id = ?", []byte(invite.Id)). + Update("accepted", true).Error; err != nil { + return err + } + + // Create userorg association + userOrg := &models.UserOrg{ + UserID: []byte(userId), + OrgID: []byte(invite.OrgId), + Admin: false, + } + + return tx.Create(userOrg).Error + }) +} + +func (r *GormRepository) GetInvites(orgId string) ([]*types.Invite, error) { + var gormInvites []models.Invite + result := r.db.Where("org_id = ?", []byte(orgId)).Find(&gormInvites) + + if result.Error != nil { + return nil, result.Error + } + + invites := make([]*types.Invite, len(gormInvites)) + for i, gormInvite := range gormInvites { + invites[i] = r.convertGormInviteToTypesInvite(&gormInvite) + } + + return invites, nil +} + +func (r *GormRepository) GetInvite(id string) (*types.Invite, error) { + var gormInvite models.Invite + result := r.db.Where("id = ?", id).First(&gormInvite) + + if result.Error != nil { + return nil, result.Error + } + + return r.convertGormInviteToTypesInvite(&gormInvite), nil +} + +func (r *GormRepository) DeleteInvite(id string) error { + return r.db.Where("id = ?", id).Delete(&models.Invite{}).Error +} + +// SessionInterface implementation (basic stubs) +func (r *GormRepository) InsertSession(session *types.Session) error { + var terminated uint64 + if session.Terminated.Valid { + terminated = uint64(util.TimeToMs(session.Terminated.Time)) + } + + gormSession := &models.Session{ + ID: []byte(session.Id), + UserID: []byte(session.UserId), + Inserted: uint64(util.TimeToMs(time.Now())), + Updated: uint64(util.TimeToMs(time.Now())), + Terminated: terminated, + } + return r.db.Create(gormSession).Error +} + +func (r *GormRepository) TerminateSession(sessionId string) error { + return r.db.Model(&models.Session{}). + Where("id = ?", []byte(sessionId)). + Update("terminated", uint64(util.TimeToMs(time.Now()))).Error +} + +func (r *GormRepository) DeleteSession(sessionId, userId string) error { + return r.db.Where("id = ? AND user_id = ?", []byte(sessionId), []byte(userId)). + Delete(&models.Session{}).Error +} + +func (r *GormRepository) UpdateSessionActivity(sessionId string) error { + return r.db.Model(&models.Session{}). + Where("id = ?", []byte(sessionId)). + Update("updated", uint64(util.TimeToMs(time.Now()))).Error +} + +// APIKey interface (basic stubs) +func (r *GormRepository) InsertApiKey(apiKey *types.ApiKey) error { + gormApiKey := &models.APIKey{ + ID: []byte(apiKey.Id), + UserID: []byte(apiKey.UserId), + Inserted: uint64(util.TimeToMs(time.Now())), + Updated: uint64(util.TimeToMs(time.Now())), + Label: apiKey.Label, + } + return r.db.Create(gormApiKey).Error +} + +func (r *GormRepository) DeleteApiKey(keyId, userId string) error { + return r.db.Where("id = ? AND user_id = ?", []byte(keyId), []byte(userId)). + Delete(&models.APIKey{}).Error +} + +func (r *GormRepository) UpdateApiKey(apiKey *types.ApiKey) error { + return r.db.Model(&models.APIKey{}). + Where("id = ?", []byte(apiKey.Id)). + Updates(map[string]interface{}{ + "updated": uint64(util.TimeToMs(time.Now())), + "label": apiKey.Label, + }).Error +} + +func (r *GormRepository) GetApiKeys(userId string) ([]*types.ApiKey, error) { + var gormApiKeys []models.APIKey + result := r.db.Where("user_id = ? AND deleted_at IS NULL", []byte(userId)). + Find(&gormApiKeys) + + if result.Error != nil { + return nil, result.Error + } + + apiKeys := make([]*types.ApiKey, len(gormApiKeys)) + for i, gormApiKey := range gormApiKeys { + apiKeys[i] = r.convertGormApiKeyToTypesApiKey(&gormApiKey) + } + + return apiKeys, nil +} + +func (r *GormRepository) UpdateApiKeyActivity(keyId string) error { + return r.db.Model(&models.APIKey{}). + Where("id = ?", []byte(keyId)). + Update("updated", uint64(util.TimeToMs(time.Now()))).Error +} + +func (r *GormRepository) convertGormApiKeyToTypesApiKey(gormApiKey *models.APIKey) *types.ApiKey { + return &types.ApiKey{ + Id: string(gormApiKey.ID), + Inserted: util.MsToTime(int64(gormApiKey.Inserted)), + Updated: util.MsToTime(int64(gormApiKey.Updated)), + UserId: string(gormApiKey.UserID), + Label: gormApiKey.Label, + } +} + +// TransactionInterface implementation +func (r *GormRepository) InsertTransaction(transaction *types.Transaction) error { + gormTransaction := &models.Transaction{ + ID: []byte(transaction.Id), + OrgID: []byte(transaction.OrgId), + UserID: []byte(transaction.UserId), + Date: uint64(transaction.Date.Unix()), + Inserted: uint64(util.TimeToMs(time.Now())), + Updated: uint64(util.TimeToMs(time.Now())), + Description: transaction.Description, + Data: transaction.Data, + } + return r.db.Create(gormTransaction).Error +} + +func (r *GormRepository) GetTransactionById(id string) (*types.Transaction, error) { + var gormTransaction models.Transaction + result := r.db.Where("id = ?", []byte(id)).First(&gormTransaction) + if result.Error != nil { + return nil, result.Error + } + return r.convertGormTransactionToTypesTransaction(&gormTransaction), nil +} + +func (r *GormRepository) GetTransactionsByAccount(accountId string, options *types.QueryOptions) ([]*types.Transaction, error) { + var gormTransactions []models.Transaction + query := r.db.Table("transactions"). + Joins("JOIN splits ON splits.transaction_id = transactions.id"). + Where("splits.account_id = ?", []byte(accountId)) + + if options != nil { + // Apply query options like limit, skip, date range, etc. + if options.Limit > 0 { + query = query.Limit(int(options.Limit)) + } + if options.Skip > 0 { + query = query.Offset(int(options.Skip)) + } + } + + result := query.Find(&gormTransactions) + if result.Error != nil { + return nil, result.Error + } + + transactions := make([]*types.Transaction, len(gormTransactions)) + for i, gormTx := range gormTransactions { + transactions[i] = r.convertGormTransactionToTypesTransaction(&gormTx) + } + + return transactions, nil +} + +func (r *GormRepository) GetTransactionsByOrg(orgId string, options *types.QueryOptions, accountIds []string) ([]*types.Transaction, error) { + var gormTransactions []models.Transaction + query := r.db.Where("org_id = ?", []byte(orgId)) + + if len(accountIds) > 0 { + // Convert string IDs to byte arrays + byteAccountIds := make([][]byte, len(accountIds)) + for i, id := range accountIds { + byteAccountIds[i] = []byte(id) + } + query = query.Where("id IN (SELECT DISTINCT transaction_id FROM splits WHERE account_id IN ?)", byteAccountIds) + } + + if options != nil { + if options.Limit > 0 { + query = query.Limit(int(options.Limit)) + } + if options.Skip > 0 { + query = query.Offset(int(options.Skip)) + } + } + + result := query.Find(&gormTransactions) + if result.Error != nil { + return nil, result.Error + } + + transactions := make([]*types.Transaction, len(gormTransactions)) + for i, gormTx := range gormTransactions { + transactions[i] = r.convertGormTransactionToTypesTransaction(&gormTx) + } + + return transactions, nil +} + +func (r *GormRepository) DeleteTransaction(id string) error { + return r.db.Where("id = ?", []byte(id)).Delete(&models.Transaction{}).Error +} + +func (r *GormRepository) DeleteAndInsertTransaction(id string, transaction *types.Transaction) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Delete the old transaction + if err := tx.Where("id = ?", []byte(id)).Delete(&models.Transaction{}).Error; err != nil { + return err + } + + // Insert the new transaction + gormTransaction := &models.Transaction{ + ID: []byte(transaction.Id), + OrgID: []byte(transaction.OrgId), + UserID: []byte(transaction.UserId), + Date: uint64(transaction.Date.Unix()), + Inserted: uint64(util.TimeToMs(time.Now())), + Updated: uint64(util.TimeToMs(time.Now())), + Description: transaction.Description, + Data: transaction.Data, + } + + return tx.Create(gormTransaction).Error + }) +} + +func (r *GormRepository) convertGormTransactionToTypesTransaction(gormTx *models.Transaction) *types.Transaction { + return &types.Transaction{ + Id: string(gormTx.ID), + OrgId: string(gormTx.OrgID), + UserId: string(gormTx.UserID), + Date: time.Unix(int64(gormTx.Date), 0), + Inserted: util.MsToTime(int64(gormTx.Inserted)), + Updated: util.MsToTime(int64(gormTx.Updated)), + Description: gormTx.Description, + Data: gormTx.Data, + Deleted: gormTx.Deleted, + } +} + +// Helper conversion functions +func (r *GormRepository) convertGormOrgToTypesOrg(gormOrg *models.Org) *types.Org { + return &types.Org{ + Id: string(gormOrg.ID), + Inserted: util.MsToTime(int64(gormOrg.Inserted)), + Updated: util.MsToTime(int64(gormOrg.Updated)), + Name: gormOrg.Name, + Currency: gormOrg.Currency, + Precision: gormOrg.Precision, + Timezone: gormOrg.Timezone, + } +} + +func (r *GormRepository) convertGormInviteToTypesInvite(gormInvite *models.Invite) *types.Invite { + return &types.Invite{ + Id: string(gormInvite.ID), + OrgId: string(gormInvite.OrgID), + Inserted: util.MsToTime(int64(gormInvite.Inserted)), + Updated: util.MsToTime(int64(gormInvite.Updated)), + Email: gormInvite.Email, + Accepted: gormInvite.Accepted, + } +} + +// Stub implementations for remaining interfaces that aren't fully used yet +func (r *GormRepository) GetPrices(orgId string, date time.Time) ([]*types.Price, error) { + // Stub implementation - add as needed + return nil, nil +} + +func (r *GormRepository) InsertPrice(price *types.Price) error { + // Stub implementation - add as needed + return nil +} + +func (r *GormRepository) Ping() error { + // Check if the database connection is alive + sqlDB, err := r.db.DB() + if err != nil { + return err + } + return sqlDB.Ping() +} + +func (r *GormRepository) InsertBudget(budget *types.Budget) error { + // Stub implementation - add as needed + return nil +} + +func (r *GormRepository) GetBudgets(orgId string) ([]*types.Budget, error) { + // Stub implementation - add as needed + return nil, nil +} \ No newline at end of file diff --git a/core/util/id/id.go b/core/util/id/id.go new file mode 100644 index 0000000..13a6b6a --- /dev/null +++ b/core/util/id/id.go @@ -0,0 +1,48 @@ +package id + +import ( + "encoding/binary" + + "github.com/google/uuid" +) + +// New creates a new binary ID (16 bytes) using UUID v4 +func New() []byte { + u := uuid.New() + return u[:] +} + +// ToUUID converts a binary ID back to UUID +func ToUUID(b []byte) (uuid.UUID, error) { + return uuid.FromBytes(b) +} + +// String returns the string representation of a binary ID +func String(b []byte) string { + u, err := uuid.FromBytes(b) + if err != nil { + return "" + } + return u.String() +} + +// FromString parses a string UUID into binary format +func FromString(s string) ([]byte, error) { + u, err := uuid.Parse(s) + if err != nil { + return nil, err + } + return u[:], nil +} + +// Uint64ToBytes converts a uint64 to 8-byte slice +func Uint64ToBytes(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +// BytesToUint64 converts 8-byte slice to uint64 +func BytesToUint64(b []byte) uint64 { + return binary.BigEndian.Uint64(b) +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..1a9d820 --- /dev/null +++ b/database/database.go @@ -0,0 +1,332 @@ +package database + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/openaccounting/oa-server/core/util/id" + "github.com/openaccounting/oa-server/models" + "gorm.io/driver/mysql" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +type Config struct { + Driver string // "mysql" or "sqlite" + Host string + Port string + User string + Password string + DBName string + SSLMode string + // SQLite specific + File string // SQLite database file path +} + +func Connect(config *Config) error { + // Configure GORM logger + newLogger := logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Info, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) + + var db *gorm.DB + var err error + + // Choose driver based on config + switch config.Driver { + case "sqlite": + // Use SQLite + dbFile := config.File + if dbFile == "" { + dbFile = "./openaccounting.db" // Default SQLite file + } + db, err = gorm.Open(sqlite.Open(dbFile), &gorm.Config{ + Logger: newLogger, + }) + case "mysql": + // Use MySQL (existing logic) + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + config.User, config.Password, config.Host, config.Port, config.DBName) + db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: newLogger, + }) + default: + return fmt.Errorf("unsupported database driver: %s (supported: mysql, sqlite)", config.Driver) + } + + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + // Configure connection pool (only for MySQL, SQLite handles this internally) + if config.Driver == "mysql" { + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("failed to get database instance: %w", err) + } + + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(25) + sqlDB.SetConnMaxLifetime(5 * time.Minute) + } + + DB = db + return nil +} + +// AutoMigrate runs automatic migrations for all models +func AutoMigrate() error { + return DB.AutoMigrate( + &models.Org{}, + &models.User{}, + &models.UserOrg{}, + &models.Token{}, + &models.Account{}, + &models.Transaction{}, + &models.Split{}, + &models.Balance{}, + &models.Permission{}, + &models.Price{}, + &models.Session{}, + &models.APIKey{}, + &models.Invite{}, + &models.BudgetItem{}, + ) +} + +// Migrate runs custom migrations +func Migrate() error { + // Create indexes + if err := createIndexes(); err != nil { + return err + } + + // Insert default data - temporarily disabled for testing + // if err := seedDefaultData(); err != nil { + // return err + // } + + return nil +} + +func createIndexes() error { + // Create custom indexes that GORM doesn't handle automatically + // Based on original indexes.sql file + indexes := []string{ + // Original indexes from indexes.sql + "CREATE INDEX IF NOT EXISTS account_orgId_index ON accounts(orgId)", + "CREATE INDEX IF NOT EXISTS split_accountId_index ON splits(accountId)", + "CREATE INDEX IF NOT EXISTS split_transactionId_index ON splits(transactionId)", + "CREATE INDEX IF NOT EXISTS split_date_index ON splits(date)", + "CREATE INDEX IF NOT EXISTS split_updated_index ON splits(updated)", + "CREATE INDEX IF NOT EXISTS budgetitem_orgId_index ON budget_items(orgId)", + + // Additional useful indexes for performance + "CREATE INDEX IF NOT EXISTS idx_transaction_date ON transactions(date)", + "CREATE INDEX IF NOT EXISTS idx_transaction_org ON transactions(orgId)", + "CREATE INDEX IF NOT EXISTS idx_account_parent ON accounts(parent)", + "CREATE INDEX IF NOT EXISTS idx_userorg_user ON user_orgs(userId)", + "CREATE INDEX IF NOT EXISTS idx_userorg_org ON user_orgs(orgId)", + "CREATE INDEX IF NOT EXISTS idx_balance_account_date ON balances(accountId, date)", + "CREATE INDEX IF NOT EXISTS idx_permission_org_account ON permissions(orgId, accountId)", + } + + for _, idx := range indexes { + if err := DB.Exec(idx).Error; err != nil { + return fmt.Errorf("failed to create index: %w", err) + } + } + + return nil +} + +func seedDefaultData() error { + // Check if we already have data + var count int64 + if err := DB.Model(&models.Org{}).Count(&count).Error; err != nil { + return err + } + + if count > 0 { + return nil // Data already exists + } + + // Create a default organization + defaultOrg := models.Org{ + ID: id.New(), // You'll need to implement this + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Default Organization", + Currency: "USD", + Precision: 2, + Timezone: "UTC", + } + + if err := DB.Create(&defaultOrg).Error; err != nil { + return fmt.Errorf("failed to create default organization: %w", err) + } + + // Create default accounts for the organization + defaultAccounts := []models.Account{ + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Assets", + Parent: []byte{0}, // Root account has zero parent + Currency: "USD", + Precision: 2, + DebitBalance: true, + }, + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Liabilities", + Parent: []byte{0}, + Currency: "USD", + Precision: 2, + DebitBalance: false, + }, + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Equity", + Parent: []byte{0}, + Currency: "USD", + Precision: 2, + DebitBalance: false, + }, + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Revenue", + Parent: []byte{0}, + Currency: "USD", + Precision: 2, + DebitBalance: false, + }, + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Expenses", + Parent: []byte{0}, + Currency: "USD", + Precision: 2, + DebitBalance: true, + }, + } + + // Create accounts and store their IDs for parent-child relationships + accountMap := make(map[string]*models.Account) + + for _, acc := range defaultAccounts { + account := acc + if err := DB.Create(&account).Error; err != nil { + return fmt.Errorf("failed to create account %s: %w", acc.Name, err) + } + accountMap[acc.Name] = &account + } + + // Create Current Assets first + var assetsParent []byte + if assetsAccount, exists := accountMap["Assets"]; exists { + assetsParent = assetsAccount.ID + } else { + return fmt.Errorf("Assets account not found in accountMap") + } + + currentAssets := models.Account{ + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Current Assets", + Parent: assetsParent, + Currency: "USD", + Precision: 2, + DebitBalance: true, + } + + if err := DB.Create(¤tAssets).Error; err != nil { + return fmt.Errorf("failed to create Current Assets: %w", err) + } + accountMap["Current Assets"] = ¤tAssets + + // Create Accounts Payable + var liabilitiesParent []byte + if liabilitiesAccount, exists := accountMap["Liabilities"]; exists { + liabilitiesParent = liabilitiesAccount.ID + } else { + return fmt.Errorf("Liabilities account not found in accountMap") + } + + accountsPayable := models.Account{ + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Accounts Payable", + Parent: liabilitiesParent, + Currency: "USD", + Precision: 2, + DebitBalance: false, + } + + if err := DB.Create(&accountsPayable).Error; err != nil { + return fmt.Errorf("failed to create Accounts Payable: %w", err) + } + + // Now create sub-accounts under Current Assets + subAccounts := []models.Account{ + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Cash", + Parent: currentAssets.ID, + Currency: "USD", + Precision: 2, + DebitBalance: true, + }, + { + ID: id.New(), + OrgID: defaultOrg.ID, + Inserted: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Updated: uint64(time.Now().UnixNano() / int64(time.Millisecond)), + Name: "Accounts Receivable", + Parent: currentAssets.ID, + Currency: "USD", + Precision: 2, + DebitBalance: true, + }, + } + + for _, acc := range subAccounts { + if err := DB.Create(&acc).Error; err != nil { + return fmt.Errorf("failed to create sub-account %s: %w", acc.Name, err) + } + } + + return nil +} diff --git a/models/account.go b/models/account.go new file mode 100644 index 0000000..d68447f --- /dev/null +++ b/models/account.go @@ -0,0 +1,18 @@ +package models + +// Account represents a financial account +type Account struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + Name string `gorm:"column:name;size:100;not null"` + Parent []byte `gorm:"column:parent;type:BINARY(16);not null"` + Currency string `gorm:"column:currency;size:10;not null"` + Precision int `gorm:"column:precision;not null"` + DebitBalance bool `gorm:"column:debitBalance;not null"` + + Org Org `gorm:"foreignKey:OrgID"` + Splits []Split `gorm:"foreignKey:AccountID"` + Balances []Balance `gorm:"foreignKey:AccountID"` +} diff --git a/models/api_key.go b/models/api_key.go new file mode 100644 index 0000000..f9ca449 --- /dev/null +++ b/models/api_key.go @@ -0,0 +1,13 @@ +package models + +// APIKey represents API keys for users +type APIKey struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + UserID []byte `gorm:"column:userId;type:BINARY(16);not null"` + Label string `gorm:"column:label;size:300;not null"` + Deleted uint64 `gorm:"column:deleted"` + + User User `gorm:"foreignKey:UserID"` +} diff --git a/models/balance.go b/models/balance.go new file mode 100644 index 0000000..39a2f7a --- /dev/null +++ b/models/balance.go @@ -0,0 +1,11 @@ +package models + +// Balance represents an account balance at a point in time +type Balance struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Date uint64 `gorm:"column:date;not null"` + AccountID []byte `gorm:"column:accountId;type:BINARY(16);not null"` + Amount int64 `gorm:"column:amount;not null"` + + Account Account `gorm:"foreignKey:AccountID"` +} diff --git a/models/base.go b/models/base.go new file mode 100644 index 0000000..a1b1ad9 --- /dev/null +++ b/models/base.go @@ -0,0 +1,45 @@ +package models + +import ( + "github.com/google/uuid" + "github.com/openaccounting/oa-server/core/util/id" + "gorm.io/gorm" +) + +type Base struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` +} + +// GetUUID converts binary ID to UUID +func (b *Base) GetUUID() (uuid.UUID, error) { + return id.ToUUID(b.ID) +} + +// GetIDString returns string representation of the ID +func (b *Base) GetIDString() string { + return id.String(b.ID) +} + +// SetIDFromString parses string UUID into binary ID +func (b *Base) SetIDFromString(s string) error { + binID, err := id.FromString(s) + if err != nil { + return err + } + b.ID = binID + return nil +} + +// ValidateID checks if the ID is a valid UUID +func (b *Base) ValidateID() error { + _, err := uuid.FromBytes(b.ID) + return err +} + +// BeforeCreate GORM hook to set ID if empty +func (b *Base) BeforeCreate(tx *gorm.DB) error { + if len(b.ID) == 0 { + b.ID = id.New() + } + return nil +} diff --git a/models/budget_item.go b/models/budget_item.go new file mode 100644 index 0000000..f67fb8e --- /dev/null +++ b/models/budget_item.go @@ -0,0 +1,13 @@ +package models + +// BudgetItem represents budget items +type BudgetItem struct { + ID uint `gorm:"primaryKey;autoIncrement"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + AccountID []byte `gorm:"column:accountId;type:BINARY(16);not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Amount int64 `gorm:"column:amount;not null"` + + Org Org `gorm:"foreignKey:OrgID"` + Account Account `gorm:"foreignKey:AccountID"` +} diff --git a/models/invite.go b/models/invite.go new file mode 100644 index 0000000..5c19c77 --- /dev/null +++ b/models/invite.go @@ -0,0 +1,13 @@ +package models + +// Invite represents organization invitations +type Invite struct { + ID string `gorm:"size:32;primaryKey"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + Email string `gorm:"column:email;size:100;not null"` + Accepted bool `gorm:"column:accepted;not null"` + + Org Org `gorm:"foreignKey:OrgID"` +} diff --git a/models/org.go b/models/org.go new file mode 100644 index 0000000..33304fd --- /dev/null +++ b/models/org.go @@ -0,0 +1,15 @@ +package models + +// Org represents an organization +type Org struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + Name string `gorm:"column:name;size:100;not null"` + Currency string `gorm:"column:currency;size:10;not null"` + Precision int `gorm:"column:precision;not null"` + Timezone string `gorm:"column:timezone;size:100;not null"` + + Accounts []Account `gorm:"foreignKey:OrgID"` + UserOrgs []UserOrg `gorm:"foreignKey:OrgID"` +} diff --git a/models/permission.go b/models/permission.go new file mode 100644 index 0000000..b4786d0 --- /dev/null +++ b/models/permission.go @@ -0,0 +1,18 @@ +package models + +// Permission represents access control rules +type Permission struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + UserID []byte `gorm:"column:userId;type:BINARY(16)"` + TokenID []byte `gorm:"column:tokenId;type:BINARY(16)"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + AccountID []byte `gorm:"column:accountId;type:BINARY(16);not null"` + Type uint `gorm:"column:type;not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + + User User `gorm:"foreignKey:UserID"` + Token Token `gorm:"foreignKey:TokenID"` + Org Org `gorm:"foreignKey:OrgID"` + Account Account `gorm:"foreignKey:AccountID"` +} diff --git a/models/price.go b/models/price.go new file mode 100644 index 0000000..c2298c7 --- /dev/null +++ b/models/price.go @@ -0,0 +1,14 @@ +package models + +// Price represents currency exchange rates +type Price struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + Currency string `gorm:"column:currency;size:10;not null"` + Date uint64 `gorm:"column:date;not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + Price float64 `gorm:"column:price;not null"` + + Org Org `gorm:"foreignKey:OrgID"` +} diff --git a/models/session.go b/models/session.go new file mode 100644 index 0000000..6fca93c --- /dev/null +++ b/models/session.go @@ -0,0 +1,12 @@ +package models + +// Session represents user sessions +type Session struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + UserID []byte `gorm:"column:userId;type:BINARY(16);not null"` + Terminated uint64 `gorm:"column:terminated"` + + User User `gorm:"foreignKey:UserID"` +} diff --git a/models/split.go b/models/split.go new file mode 100644 index 0000000..4793414 --- /dev/null +++ b/models/split.go @@ -0,0 +1,17 @@ +package models + +// Split represents a single entry in a transaction +type Split struct { + ID uint `gorm:"primaryKey;autoIncrement"` + TransactionID []byte `gorm:"column:transactionId;type:BINARY(16);not null"` + AccountID []byte `gorm:"column:accountId;type:BINARY(16);not null"` + Date uint64 `gorm:"column:date;not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + Amount int64 `gorm:"column:amount;not null"` + NativeAmount int64 `gorm:"column:nativeAmount;not null"` + Deleted bool `gorm:"column:deleted;default:false"` + + Transaction Transaction `gorm:"foreignKey:TransactionID"` + Account Account `gorm:"foreignKey:AccountID"` +} diff --git a/models/token.go b/models/token.go new file mode 100644 index 0000000..a2d552e --- /dev/null +++ b/models/token.go @@ -0,0 +1,10 @@ +package models + +// Token represents an API token +type Token struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + Name string `gorm:"column:name;size:100"` + UserOrgID uint `gorm:"column:userOrgId;not null"` + + UserOrg UserOrg `gorm:"foreignKey:UserOrgID"` +} diff --git a/models/transaction.go b/models/transaction.go new file mode 100644 index 0000000..128de5e --- /dev/null +++ b/models/transaction.go @@ -0,0 +1,18 @@ +package models + +// Transaction represents a financial transaction +type Transaction struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + UserID []byte `gorm:"column:userId;type:BINARY(16);not null"` + Date uint64 `gorm:"column:date;not null"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + Description string `gorm:"column:description;size:300;not null"` + Data string `gorm:"column:data;type:TEXT;not null"` + Deleted bool `gorm:"column:deleted;default:false"` + + Org Org `gorm:"foreignKey:OrgID"` + User User `gorm:"foreignKey:UserID"` + Splits []Split `gorm:"foreignKey:TransactionID"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..257a1b5 --- /dev/null +++ b/models/user.go @@ -0,0 +1,21 @@ +package models + +// User represents a user account +type User struct { + ID []byte `gorm:"type:BINARY(16);primaryKey"` + Inserted uint64 `gorm:"column:inserted;not null"` + Updated uint64 `gorm:"column:updated;not null"` + FirstName string `gorm:"column:firstName;size:50;not null"` + LastName string `gorm:"column:lastName;size:50;not null"` + Email string `gorm:"column:email;size:100;not null;unique"` + PasswordHash string `gorm:"column:passwordHash;size:100;not null"` + AgreeToTerms bool `gorm:"column:agreeToTerms;not null"` + PasswordReset string `gorm:"column:passwordReset;size:32;not null"` + EmailVerified bool `gorm:"column:emailVerified;not null"` + EmailVerifyCode string `gorm:"column:emailVerifyCode;size:32;not null"` + SignupSource string `gorm:"column:signupSource;size:100;not null"` + + UserOrgs []UserOrg `gorm:"foreignKey:UserID"` + Sessions []Session `gorm:"foreignKey:UserID"` + APIKeys []APIKey `gorm:"foreignKey:UserID"` +} diff --git a/models/user_org.go b/models/user_org.go new file mode 100644 index 0000000..3655fef --- /dev/null +++ b/models/user_org.go @@ -0,0 +1,12 @@ +package models + +// UserOrg represents the relationship between users and organizations +type UserOrg struct { + ID uint `gorm:"primaryKey;autoIncrement"` + UserID []byte `gorm:"column:userId;type:BINARY(16);not null"` + OrgID []byte `gorm:"column:orgId;type:BINARY(16);not null"` + Admin bool `gorm:"column:admin;default:false"` + + User User `gorm:"foreignKey:UserID"` + Org Org `gorm:"foreignKey:OrgID"` +}