# Build binary
go build -o app
# Build for Linux (deployment)
set GOOS=linux && go build -ldflags "-s -w" -o app
# Run directly
go run main.go
# Run with live reload (requires nodemon or air)
nodemon --watch . --ext go --exec go run main.go
# Tidy dependencies
go mod tidy# Run all tests
go test ./...
# Run single package tests
go test ./validations/...
# Run single test function
go test -run TestFunctionName ./path/to/package
# Verbose
go test -v -run TestFunctionName ./path/to/package├── main.go # Entry point
├── internal/ # Domain packages (handler/service/repository)
├── models/ # GORM models + search helpers
├── validations/ # Input validation structs
└── .env.example # Environment template
Three groups separated by blank lines, each sorted alphabetically:
- Standard library
- Third-party (external modules)
- Internal/local (
github.com/haditssoft/haditssoft-backend/...)
Local imports use aliases with descriptive prefixes when package name differs:
import (
"errors"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"github.com/haditssoft/haditssoft-backend/databases/connections"
"github.com/haditssoft/haditssoft-backend/models"
noteValidations "github.com/haditssoft/haditssoft-backend/validations/note"
)- Tabs for indentation (standard
go fmt) - File naming pattern:
<name>.<category>.go(e.g.auth.controller.go,user.model.go,auths.route.go,create.user.validation.go) - Run
go fmt ./...before committing
- PascalCase for exported types:
UserCreate,AdminMainData,UserResponseField - JSON tags in snake_case:
json:"created_at" - GORM tags with
gorm:"notNull;size:50" - Form tags alongside JSON tags on validation structs:
form:"email" json:"email" - Response DTOs in
responses/package, suffixResponseField
- Exported functions: PascalCase (
LoadMainData,ValidateModel) - Unexported functions: camelCase (
getUserByEmail,dbColumnName) - Variables: camelCase (
modelValidation,allErrors,responseModel) - Interfaces: PascalCase (
CRUDController,AuthController) - Package-level vars: PascalCase if exported (
DB,TrCtx), camelCase if private (uni,validate) - Constants: PascalCase (
TrCtx,ErrorWhenValidate) - File naming:
<name>.<category>.gopattern throughout
- Define struct types with empty bodies:
type Auth struct{} - Implement interfaces from
controllers/controller.go:CRUDController(GetList, GetOne, GetSome, Create, Update, DeleteOne, DeleteSome)AuthController(Login, Logout, Identity, Refresh)OptionsController(GetDataForSelect)
- Method receivers: pointer
(ctl *Auth)
- Function signature:
func RouteName(rg fiber.Router) - Group endpoints under a path:
app := rg.Group("/auths") - Dispatch via interface:
var ctrl controllers.AuthController = new(front.Auth) - Or directly:
ctrl := new(front.MainData)
- Controllers return
c.Status(code).JSON(fiber.Map{...})— never panic - GORM errors: check
errors.Is(err, gorm.ErrRecordNotFound)andresult.RowsAffected - Transactions:
connections.DB.Transaction(func(tx *gorm.DB) error { return err }) - Body parsing errors: return
fiber.StatusInternalServerError - Validation errors: return
fiber.StatusBadRequestwithfiber.Map{"errors": allErrors} - Success responses:
c.JSON(responseModel)orc.SendStatus(fiber.StatusNoContent)
- Package
models, table name viafunc (User) TableName() string { return "User" } - Naming strategy:
NoLowerCase: true,SingularTable: true(PascalCase table/column names) - Hooks:
BeforeSave,BeforeCreate,BeforeUpdate,AfterFind,AfterCreate,AfterUpdate,AfterDelete - Activity logging within same transaction for Create/Update/Delete
- DB access via
connections.DBsingleton
- Validation structs in
validations/<entity>/withvalidate:"..."tags - Central engine in
validations/validator.gowithValidateModel(model)returningmap[string]interface{} - Custom validators registered via
validations.RegisterCustomValidations() - Error messages via
errorMessage()switch function
- JWT auth:
middlewares.Protected()fromgithub.com/gofiber/jwt/v3 - Admin guard:
middlewares.IsAdminblocks non-admin users - Context propagation via
SetConexContext(c)inconexContext.middleware.go - Response shape for auth errors:
{"status": "error", "message": "...", "data": nil}
- Framework: Fiber v2 with
Prefork: true - CORS:
AllowOrigins: "*",AllowHeaders: "*", exposeX-Total-Count - Static files:
app.Static("/", "./storage") - Pagination responses:
{"data": results, "total": total, "page": page, "limit": limit} - Success shape:
{"status": "success", "message": "...", "token": ...}(varies) - Login/Refresh response includes
refresh_tokenalongsidetoken - Copy models to response DTOs:
copier.Copy(responseModel, &model)
- Access token: short-lived (15 min), signed JWT with
user_id+emailclaims - Refresh token: long-lived (7 days), opaque random string stored as SHA-256 hash in
RefreshTokentable - Rotation: each refresh marks the old token as
is_used = true, inserts a new record - Reuse detection: if a refresh token is presented when
is_used = true, all the user's tokens are revoked (force re-login) - Endpoints:
POST /auths/login→{"token": "<access>", "refresh_token": "<plain>"}POST /auths/refresh(body:{"refresh_token": "..."}) →{"token": "<new_access>", "refresh_token": "<new_plain>"}POST /auths/logout(protected) → blacklists access token
- Env:
JWT_SECRETenv var (falls back to hardcoded value) - Constants:
AccessTokenExpiry = 15min,RefreshTokenExpiry = 7dinauthentications/authentication.go
Two search strategies available, frontend chooses which to call:
Single kitab (original, unchanged):
POST /searchHadits/:kitabName/:column- Body:
{"keyword": ["..."]} - Returns:
[rows, "SEARCHRESULTCOUNT", kitabName]
Multi kitab (concurrent, new):
POST /searchHadits/all/:column- Body:
{"keyword": ["..."], "books": ["ShahihBukhari", "ShahihMuslim"]} - Searches specified books concurrently via goroutines
- Returns single JSON with results grouped by kitab:
{
"ShahihBukhari": { "rows": [...], "count": 5 },
"ShahihMuslim": { "rows": [...], "count": 3 },
"total": 8
}booksis required (400 if missing/empty)- Uses
searchOneKitabhelper which dispatches tosingleKeywordSearch,multiKeywordLikeSearch, orindonesiaFTSearchbased on keyword count - DB pool:
SetMaxOpenConns(10)+ WAL mode enables concurrent reads
POST /ai/cron/translate/:kitabName?key=<OPENCODE_CRON_KEY>&limit=10- Cron-only: guarded by
OPENCODE_CRON_KEYenv var passed as?key=query param (constant-time compare,401if missing/wrong) — NOT JWT-protected kitabNamemust be inmodels.GetIndexOfKitabwhitelist (400otherwise)- Selects rows where
English IS NULL OR English = '', ordered byNomer, limited by?limit=(default 10, must be ≥ 1) - For each row sequentially: runs
opencode run --format json --agent translatewith the Arabic+Indonesian prompt, parses NDJSON response for text events, writes the returned English back viaUPDATE - Translation instructions are defined in
.opencode/agents/translate.md(automatically loaded by--agent translate) - Per-record CLI/db failures are collected, the batch continues; empty/whitespace CLI replies are counted as failed (not written back)
- Response:
{"processed": n, "updated": m, "failed": [{"nomer": x, "error": "..."}]}