Compare commits
2 Commits
0379ea4ae4
...
233f3cdb5c
Author | SHA1 | Date | |
---|---|---|---|
233f3cdb5c | |||
da115dc3f6 |
14
.clinerules
14
.clinerules
@ -34,6 +34,14 @@ SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY.
|
||||
- make run: Start the development server
|
||||
9. CUSTOM RULES
|
||||
- Add custom rules to .clinerules if:
|
||||
- Unexpected behavior is encountered
|
||||
- Specific conditions require warnings
|
||||
- New patterns emerge that need documentation
|
||||
- Unexpected behavior is encountered
|
||||
- Specific conditions require warnings
|
||||
- New patterns emerge that need documentation
|
||||
10.Implement a REST API update handling in Go using Gin that ensures the following behavior:
|
||||
- The update request is received as JSON.
|
||||
- If a field is present in the JSON and set to null, the corresponding value in the database should be removed.
|
||||
- If a field is missing in the JSON, it should not be modified.
|
||||
- If a field is present in the JSON and not null, it should be updated.
|
||||
- Use either a struct or a map to handle the JSON data.
|
||||
- Ensure the update logic is robust and does not unintentionally remove or overwrite fields.
|
||||
- Optional: Handle error cases like invalid JSON and return appropriate HTTP status codes.
|
@ -99,6 +99,6 @@ swagger:
|
||||
# Generate TypeScript types
|
||||
generate-ts:
|
||||
@echo "Generating TypeScript types..."
|
||||
@tygo generate
|
||||
@go run scripts/fix_tygo.go
|
||||
@echo "TypeScript types generated"
|
||||
|
||||
|
@ -70,7 +70,7 @@ func seedDatabase(ctx context.Context) error {
|
||||
adminUser := models.User{
|
||||
Email: "admin@example.com",
|
||||
Role: models.RoleAdmin,
|
||||
CompanyID: defaultCompany.ID,
|
||||
CompanyID: &defaultCompany.ID,
|
||||
HourlyRate: 100.0,
|
||||
}
|
||||
|
||||
|
@ -163,11 +163,43 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
// Set ID from URL
|
||||
userUpdateDTO.ID = id.String()
|
||||
|
||||
// Convert DTO to model
|
||||
userUpdate := convertUpdateDTOToModel(userUpdateDTO)
|
||||
// Set ID from URL
|
||||
userUpdateDTO.ID = id.String()
|
||||
|
||||
// Convert DTO to Model
|
||||
idWrapper := models.FromULID(id)
|
||||
update := models.UserUpdate{
|
||||
ID: idWrapper,
|
||||
}
|
||||
|
||||
if userUpdateDTO.Email != nil {
|
||||
update.Email = userUpdateDTO.Email
|
||||
}
|
||||
if userUpdateDTO.Password != nil {
|
||||
update.Password = userUpdateDTO.Password
|
||||
}
|
||||
if userUpdateDTO.Role != nil {
|
||||
update.Role = userUpdateDTO.Role
|
||||
}
|
||||
if userUpdateDTO.CompanyID != nil {
|
||||
if userUpdateDTO.CompanyID.Valid {
|
||||
companyID, err := models.ULIDWrapperFromString(*userUpdateDTO.CompanyID.Value)
|
||||
if err != nil {
|
||||
utils.BadRequestResponse(c, "Invalid company ID format")
|
||||
return
|
||||
}
|
||||
update.CompanyID = &companyID
|
||||
} else {
|
||||
update.CompanyID = nil
|
||||
|
||||
}
|
||||
}
|
||||
if userUpdateDTO.HourlyRate != nil {
|
||||
update.HourlyRate = userUpdateDTO.HourlyRate
|
||||
}
|
||||
|
||||
// Update user in the database
|
||||
user, err := models.UpdateUser(c.Request.Context(), userUpdate)
|
||||
user, err := models.UpdateUser(c.Request.Context(), update)
|
||||
if err != nil {
|
||||
utils.InternalErrorResponse(c, "Error updating user: "+err.Error())
|
||||
return
|
||||
@ -348,20 +380,27 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) {
|
||||
// Helper functions for DTO conversion
|
||||
|
||||
func convertUserToDTO(user *models.User) dto.UserDto {
|
||||
var companyID *string
|
||||
if user.CompanyID != nil {
|
||||
s := user.CompanyID.String()
|
||||
companyID = &s
|
||||
}
|
||||
return dto.UserDto{
|
||||
ID: user.ID.String(),
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
CompanyID: user.CompanyID.String(),
|
||||
CompanyID: companyID,
|
||||
HourlyRate: user.HourlyRate,
|
||||
}
|
||||
}
|
||||
|
||||
func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
||||
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
||||
companyID, _ := models.ULIDWrapperFromString(dto.CompanyID)
|
||||
var companyID models.ULIDWrapper
|
||||
if dto.CompanyID != nil && dto.CompanyID.Valid {
|
||||
companyID, _ = models.ULIDWrapperFromString(*dto.CompanyID.Value) // Ignoring error, validation happens in the model
|
||||
}
|
||||
|
||||
return models.UserCreate{
|
||||
Email: dto.Email,
|
||||
@ -371,34 +410,3 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate {
|
||||
HourlyRate: dto.HourlyRate,
|
||||
}
|
||||
}
|
||||
|
||||
func convertUpdateDTOToModel(dto dto.UserUpdateDto) models.UserUpdate {
|
||||
id, _ := ulid.Parse(dto.ID)
|
||||
update := models.UserUpdate{
|
||||
ID: models.FromULID(id),
|
||||
}
|
||||
|
||||
if dto.Email != nil {
|
||||
update.Email = dto.Email
|
||||
}
|
||||
|
||||
if dto.Password != nil {
|
||||
update.Password = dto.Password
|
||||
}
|
||||
|
||||
if dto.Role != nil {
|
||||
update.Role = dto.Role
|
||||
}
|
||||
|
||||
if dto.CompanyID != nil {
|
||||
// Convert CompanyID from int to ULID (this is a simplification, adjust as needed)
|
||||
companyID, _ := models.ULIDWrapperFromString(*dto.CompanyID)
|
||||
update.CompanyID = &companyID
|
||||
}
|
||||
|
||||
if dto.HourlyRate != nil {
|
||||
update.HourlyRate = dto.HourlyRate
|
||||
}
|
||||
|
||||
return update
|
||||
}
|
||||
|
33
backend/internal/dtos/helper/nullstring.go
Normal file
33
backend/internal/dtos/helper/nullstring.go
Normal file
@ -0,0 +1,33 @@
|
||||
package helper
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type NullString struct {
|
||||
String string
|
||||
IsNull bool
|
||||
}
|
||||
|
||||
// Serialization
|
||||
func (ns NullString) MarshalJSON() ([]byte, error) {
|
||||
// If Valid is true, return the JSON serialization result of String.
|
||||
if ns.IsNull {
|
||||
return []byte(`"` + ns.String + `"`), nil
|
||||
}
|
||||
// If Valid is false, return the serialization result of null.
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
// Deserialization
|
||||
func (ns *NullString) UnmarshalJSON(data []byte) error {
|
||||
// If data is null, set Valid to false and String to an empty string.
|
||||
if string(data) == "null" {
|
||||
ns.String, ns.IsNull = "", false
|
||||
return nil
|
||||
}
|
||||
// Otherwise, deserialize data to String and set Valid to true.
|
||||
if err := json.Unmarshal(data, &ns.String); err != nil {
|
||||
return err
|
||||
}
|
||||
ns.IsNull = true
|
||||
return nil
|
||||
}
|
@ -2,6 +2,8 @@ package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/timetracker/backend/internal/types"
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
@ -11,26 +13,26 @@ type UserDto struct {
|
||||
LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Email string `json:"email" example:"test@example.com"`
|
||||
Role string `json:"role" example:"admin"`
|
||||
CompanyID string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Email string `json:"email" example:"test@example.com"`
|
||||
Password string `json:"password" example:"password123"`
|
||||
Role string `json:"role" example:"admin"`
|
||||
CompanyID string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||
Email string `json:"email" example:"test@example.com"`
|
||||
Password string `json:"password" example:"password123"`
|
||||
Role string `json:"role" example:"admin"`
|
||||
CompanyID *types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate float64 `json:"hourlyRate" example:"50.00"`
|
||||
}
|
||||
|
||||
type UserUpdateDto struct {
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Email *string `json:"email" example:"test@example.com"`
|
||||
Password *string `json:"password" example:"password123"`
|
||||
Role *string `json:"role" example:"admin"`
|
||||
CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate *float64 `json:"hourlyRate" example:"50.00"`
|
||||
ID string `json:"id" example:"01HGW2BBG0000000000000000"`
|
||||
CreatedAt *time.Time `json:"createdAt" example:"2024-01-01T00:00:00Z"`
|
||||
UpdatedAt *time.Time `json:"updatedAt" example:"2024-01-01T00:00:00Z"`
|
||||
LastEditorID *string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"`
|
||||
Email *string `json:"email" example:"test@example.com"`
|
||||
Password *string `json:"password" example:"password123"`
|
||||
Role *string `json:"role" example:"admin"`
|
||||
CompanyID *types.Nullable[string] `json:"companyId" example:"01HGW2BBG0000000000000000"`
|
||||
HourlyRate *float64 `json:"hourlyRate" example:"50.00"`
|
||||
}
|
||||
|
@ -35,12 +35,12 @@ const (
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
EntityBase
|
||||
Email string `gorm:"column:email;unique;not null"`
|
||||
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
|
||||
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
||||
Role string `gorm:"column:role;not null;default:'user'"`
|
||||
CompanyID ULIDWrapper `gorm:"column:company_id;type:bytea;not null;index"`
|
||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||
Email string `gorm:"column:email;unique;not null"`
|
||||
Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Base64-encoded Salt
|
||||
Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Base64-encoded Hash
|
||||
Role string `gorm:"column:role;not null;default:'user'"`
|
||||
CompanyID *ULIDWrapper `gorm:"column:company_id;type:bytea;index"`
|
||||
HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"`
|
||||
|
||||
// Relationship for Eager Loading
|
||||
Company *Company `gorm:"foreignKey:CompanyID"`
|
||||
@ -335,10 +335,22 @@ func GetAllUsers(ctx context.Context) ([]User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// getCompanyCondition builds the company condition for queries
|
||||
func getCompanyCondition(companyID *ULIDWrapper) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
if companyID == nil {
|
||||
return db.Where("company_id IS NULL")
|
||||
}
|
||||
return db.Where("company_id = ?", *companyID)
|
||||
}
|
||||
}
|
||||
|
||||
// GetUsersByCompanyID returns all users of a company
|
||||
func GetUsersByCompanyID(ctx context.Context, companyID ULIDWrapper) ([]User, error) {
|
||||
var users []User
|
||||
result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users)
|
||||
// Apply the dynamic company condition
|
||||
condition := getCompanyCondition(&companyID)
|
||||
result := GetEngine(ctx).Scopes(condition).Find(&users)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
@ -386,7 +398,7 @@ func CreateUser(ctx context.Context, create UserCreate) (*User, error) {
|
||||
Salt: pwData.Salt,
|
||||
Hash: pwData.Hash,
|
||||
Role: create.Role,
|
||||
CompanyID: create.CompanyID,
|
||||
CompanyID: &create.CompanyID,
|
||||
HourlyRate: create.HourlyRate,
|
||||
}
|
||||
|
||||
@ -435,7 +447,7 @@ func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) {
|
||||
}
|
||||
|
||||
// If CompanyID is updated, check if it exists
|
||||
if update.CompanyID != nil && update.CompanyID.Compare(user.CompanyID) != 0 {
|
||||
if update.CompanyID != nil && (user.CompanyID == nil || update.CompanyID.Compare(*user.CompanyID) != 0) {
|
||||
var companyCount int64
|
||||
if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil {
|
||||
return fmt.Errorf("error checking company: %w", err)
|
||||
|
48
backend/internal/types/nullable.go
Normal file
48
backend/internal/types/nullable.go
Normal file
@ -0,0 +1,48 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Nullable[T] - Generischer Typ für optionale Werte (nullable fields)
|
||||
type Nullable[T any] struct {
|
||||
Value *T // Der tatsächliche Wert (kann nil sein)
|
||||
Valid bool // Gibt an, ob der Wert gesetzt wurde
|
||||
}
|
||||
|
||||
// NewNullable erstellt eine gültige Nullable-Instanz
|
||||
func NewNullable[T any](value T) Nullable[T] {
|
||||
return Nullable[T]{Value: &value, Valid: true}
|
||||
}
|
||||
|
||||
// Null erstellt eine leere Nullable-Instanz (ungesetzt)
|
||||
func Null[T any]() Nullable[T] {
|
||||
return Nullable[T]{Valid: false}
|
||||
}
|
||||
|
||||
// MarshalJSON - Serialisiert `Nullable[T]` korrekt ins JSON-Format
|
||||
func (n Nullable[T]) MarshalJSON() ([]byte, error) {
|
||||
if !n.Valid {
|
||||
return []byte("null"), nil // Wenn nicht valid, dann NULL
|
||||
}
|
||||
return json.Marshal(n.Value) // Serialisiert den tatsächlichen Wert
|
||||
}
|
||||
|
||||
// UnmarshalJSON - Deserialisiert JSON in `Nullable[T]`
|
||||
func (n *Nullable[T]) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
n.Valid = true // Wert wurde gesetzt, aber auf NULL
|
||||
n.Value = nil // Explizit NULL setzen
|
||||
return nil
|
||||
}
|
||||
|
||||
var v T
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return fmt.Errorf("invalid JSON for Nullable: %w", err)
|
||||
}
|
||||
|
||||
n.Value = &v
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
62
backend/scripts/fix_tygo.go
Normal file
62
backend/scripts/fix_tygo.go
Normal file
@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
dtoFilePath = "../frontend/src/types/dto.ts"
|
||||
importStatement = `import { Nullable } from "./nullable";`
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Run Tygo first
|
||||
fmt.Println("🔄 Running tygo...")
|
||||
if err := runTygo(); err != nil {
|
||||
fmt.Println("❌ Error running tygo:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Read dto.ts file
|
||||
content, err := os.ReadFile(dtoFilePath)
|
||||
if err != nil {
|
||||
fmt.Println("❌ Could not read dto.ts:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Convert to string
|
||||
dtoContent := string(content)
|
||||
|
||||
// Check if import already exists
|
||||
if strings.Contains(dtoContent, importStatement) {
|
||||
fmt.Println("ℹ️ Import already exists in dto.ts, skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
// Add import statement at the beginning
|
||||
newContent := importStatement + "\n" + dtoContent
|
||||
if err := os.WriteFile(dtoFilePath, []byte(newContent), 0644); err != nil {
|
||||
fmt.Println("❌ Error writing dto.ts:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully added Nullable<T> import to dto.ts")
|
||||
}
|
||||
|
||||
// Runs Tygo command
|
||||
func runTygo() error {
|
||||
cmd := "tygo"
|
||||
output, err := exec.Command(cmd, "generate").CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Println("Tygo output:", string(output))
|
||||
return err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
fmt.Println("Tygo output:", string(output))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
@ -3,4 +3,5 @@ packages:
|
||||
type_mappings:
|
||||
"time.Time": "string"
|
||||
"ulid.ULID": "string"
|
||||
"types.Nullable": "Nullable"
|
||||
output_path: ../frontend/src/types/dto.ts
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Nullable } from "./nullable";
|
||||
// Code generated by tygo. DO NOT EDIT.
|
||||
|
||||
//////////
|
||||
@ -160,14 +161,14 @@ export interface UserDto {
|
||||
lastEditorID: string;
|
||||
email: string;
|
||||
role: string;
|
||||
companyId: string;
|
||||
companyId?: string;
|
||||
hourlyRate: number /* float64 */;
|
||||
}
|
||||
export interface UserCreateDto {
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
companyId: string;
|
||||
companyId?: Nullable<string>;
|
||||
hourlyRate: number /* float64 */;
|
||||
}
|
||||
export interface UserUpdateDto {
|
||||
@ -178,6 +179,6 @@ export interface UserUpdateDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
role?: string;
|
||||
companyId?: string;
|
||||
companyId?: Nullable<string>;
|
||||
hourlyRate?: number /* float64 */;
|
||||
}
|
||||
|
1
frontend/src/types/nullable.ts
Normal file
1
frontend/src/types/nullable.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Nullable<T> = T | null;
|
Loading…
x
Reference in New Issue
Block a user