refactor: Separate CV domain and UI presentation models into distinct packages

**Main Changes:**

1. **Package Restructuring** - Separated mixed concerns into focused packages:
   - Created `internal/models/cv/` for CV domain logic (CV, Personal, Experience, etc.)
   - Created `internal/models/ui/` for UI presentation logic (InfoModal, ShortcutsModal, etc.)
   - Removed monolithic `internal/models/cv.go` (300+ lines → organized packages)

2. **Testing** - Added comprehensive unit tests:
   - `internal/models/cv/loader_test.go` - CV data loading and validation
   - `internal/models/ui/loader_test.go` - UI translations loading
   - All tests passing 

3. **Documentation** - Added Go learning knowledge base:
   - `_go-learning/architecture/server-design.md` - Goroutines, graceful shutdown explained
   - `_go-learning/refactorings/001-cv-model-separation.md` - This refactoring documented
   - Public documentation showcasing Go expertise (README.md kept private)

4. **Handler Updates** - Updated imports to use new package structure:
   - `internal/handlers/cv.go` - Uses `cvmodel` and `uimodel` aliases

**Benefits:**
-  Clear separation of concerns (domain vs presentation)
-  Better testability (isolated package testing)
-  Improved maintainability (smaller, focused files)
-  Scalability (each domain can evolve independently)
-  Follows Go best practices (small, cohesive packages)

**Other Updates:**
- Updated middleware security checks
- Template improvements
- Organized completed prompts

**Testing:**
- All Go unit tests pass (cv, ui, handlers)
- Server verified with curl tests (English, Spanish, Health endpoints)
- Frontend functionality unchanged (refactoring is transparent to UI)
This commit is contained in:
juanatsap
2025-11-20 16:17:56 +00:00
parent 6999d026c1
commit 7b60fdcf9c
16 changed files with 1890 additions and 15 deletions
+11 -10
View File
@@ -11,7 +11,8 @@ import (
"strings"
"time"
"github.com/juanatsap/cv-site/internal/models"
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
"github.com/juanatsap/cv-site/internal/pdf"
"github.com/juanatsap/cv-site/internal/templates"
)
@@ -53,14 +54,14 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
}
// Load CV data
cv, err := models.LoadCV(lang)
cv, err := cvmodel.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Load UI translations
ui, err := models.LoadUI(lang)
ui, err := uimodel.LoadUI(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "UI"))
return
@@ -161,14 +162,14 @@ func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) {
}
// Load CV data
cv, err := models.LoadCV(lang)
cv, err := cvmodel.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Load UI translations
ui, err := models.LoadUI(lang)
ui, err := uimodel.LoadUI(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "UI"))
return
@@ -345,7 +346,7 @@ func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version)
// Load CV data to get name for filename
cv, err := models.LoadCV(lang)
cv, err := cvmodel.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
@@ -441,7 +442,7 @@ func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars
// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field
func splitSkills(skills []models.SkillCategory) (left, right []models.SkillCategory) {
func splitSkills(skills []cvmodel.SkillCategory) (left, right []cvmodel.SkillCategory) {
if len(skills) == 0 {
return nil, nil
}
@@ -570,7 +571,7 @@ func calculateDuration(startDate, endDate string, current bool, lang string) str
// processProjectDates calculates dynamic dates for projects
// If a project has a gitRepoUrl, it fetches the first commit date
// For current projects, it sets the current system date
func processProjectDates(project *models.Project, lang string) {
func processProjectDates(project *cvmodel.Project, lang string) {
now := time.Now()
// Set dynamic current date for ongoing projects
@@ -718,13 +719,13 @@ func getGitRepoFirstCommitDate(repoPath string) string {
// prepareTemplateData prepares common template data used across handlers
func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) {
// Load CV data
cv, err := models.LoadCV(lang)
cv, err := cvmodel.LoadCV(lang)
if err != nil {
return nil, err
}
// Load UI translations
ui, err := models.LoadUI(lang)
ui, err := uimodel.LoadUI(lang)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -30,11 +30,11 @@ func SecurityHeaders(next http.Handler) http.Handler {
// Content Security Policy (comprehensive)
csp := "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://matomo.drolo.club; " +
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://matomo.morenorub.io; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.iconify.design https://matomo.drolo.club; " +
"connect-src 'self' https://api.iconify.design https://matomo.morenorub.io; " +
"frame-ancestors 'self'; " +
"base-uri 'self'; " +
"form-action 'self'"
+149
View File
@@ -0,0 +1,149 @@
package cv
// CV represents the complete curriculum vitae structure
type CV struct {
Personal Personal `json:"personal"`
Summary string `json:"summary"`
Experience []Experience `json:"experience"`
Education []Education `json:"education"`
Skills Skills `json:"skills"`
Languages []Language `json:"languages"`
Projects []Project `json:"projects"`
Awards []Award `json:"awards"`
Certifications []Certification `json:"certifications"`
Courses []Course `json:"courses"`
References []Reference `json:"references"`
Other Other `json:"other"`
Meta Meta `json:"meta"`
}
type Personal struct {
Name string `json:"name"`
Title string `json:"title"`
Location string `json:"location"`
Email string `json:"email"`
Phone string `json:"phone"`
DateOfBirth string `json:"dateOfBirth"`
PlaceOfBirth string `json:"placeOfBirth"`
Citizenship string `json:"citizenship"`
LinkedIn string `json:"linkedin"`
GitHub string `json:"github"`
Domestika string `json:"domestika"`
Website string `json:"website"`
Photo string `json:"photo"`
}
type Experience struct {
Position string `json:"position"`
Company string `json:"company"`
CompanyURL string `json:"companyURL,omitempty"` // Optional URL for company website
CompanyLogo string `json:"companyLogo"`
Location string `json:"location"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
Current bool `json:"current"`
Expired bool `json:"expired,omitempty"` // Optional flag for expired companies
ShortDescription string `json:"shortDescription"`
Responsibilities []string `json:"responsibilities"`
Technologies []string `json:"technologies"`
Highlights []string `json:"highlights"`
Duration string `json:"-"` // Calculated field, not from JSON
}
type Education struct {
Degree string `json:"degree"`
Institution string `json:"institution"`
Location string `json:"location"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
Field string `json:"field"`
}
type Skills struct {
Technical []SkillCategory `json:"technical"`
SoftSkills []string `json:"soft_skills"`
}
type SkillCategory struct {
Category string `json:"category"`
Proficiency int `json:"proficiency"`
Items []string `json:"items"`
Sidebar string `json:"sidebar"` // "left" or "right"
}
type Language struct {
Language string `json:"language"`
Proficiency string `json:"proficiency"`
Level int `json:"level"`
Detail string `json:"detail,omitempty"` // Optional detail like "Oral (Medio/Alto) Escrito (Alto)"
}
type Project struct {
Title string `json:"title"`
ProjectName string `json:"projectName,omitempty"` // Optional: linkable part of title
ProjectDesc string `json:"projectDesc,omitempty"` // Optional: non-linkable description part
URL string `json:"url"`
ProjectLogo string `json:"projectLogo,omitempty"` // Optional logo filename
GitRepoUrl string `json:"gitRepoUrl,omitempty"` // Optional git repository URL for dynamic dates
Location string `json:"location"`
StartDate string `json:"startDate,omitempty"` // Optional static start date
Current bool `json:"current"`
MaintainedBy string `json:"maintainedBy,omitempty"` // Optional maintainer name (e.g., "SAP")
Technologies []string `json:"technologies"`
ShortDescription string `json:"shortDescription"`
Responsibilities []string `json:"responsibilities"`
// Computed fields (not stored in JSON)
ComputedStartDate string `json:"-"` // Dynamically calculated from git repo or system
DynamicDate string `json:"-"` // Current date for ongoing projects
}
type Award struct {
Title string `json:"title"`
Issuer string `json:"issuer"`
Date string `json:"date"`
Description string `json:"description"`
ShortDescription string `json:"shortDescription,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"`
AwardLogo string `json:"awardLogo"`
}
type Certification struct {
Name string `json:"name"`
Issuer string `json:"issuer"`
Date string `json:"date"`
Description string `json:"description"`
}
type Course struct {
Title string `json:"title"`
Institution string `json:"institution"`
CourseLogo string `json:"courseLogo,omitempty"` // Optional logo filename
Location string `json:"location"`
Date string `json:"date"`
Duration string `json:"duration"`
Description string `json:"description"`
ShortDescription string `json:"shortDescription,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"`
}
type Reference struct {
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"` // "recommendation", "portfolio", "profile", "cv", "presentation"
Action string `json:"action,omitempty"` // Optional action like "downloadPDF"
TextBefore string `json:"textBefore,omitempty"` // Text before the link
LinkText string `json:"linkText,omitempty"` // Bold text inside the link
TextAfter string `json:"textAfter,omitempty"` // Text after the link
}
type Other struct {
DriverLicense string `json:"driverLicense"`
}
type Meta struct {
Version string `json:"version"`
LastUpdated string `json:"lastUpdated"`
Format string `json:"format"`
Language string `json:"language"`
}
+69
View File
@@ -0,0 +1,69 @@
package cv
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// LoadCV loads CV data from a JSON file for the specified language
func LoadCV(lang string) (*CV, error) {
if lang != "en" && lang != "es" {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
filename := fmt.Sprintf("data/cv-%s.json", lang)
filepath, err := findDataFile(filename)
if err != nil {
return nil, err
}
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
}
var cvData CV
if err := json.Unmarshal(data, &cvData); err != nil {
return nil, fmt.Errorf("error parsing JSON: %w", err)
}
// Replace {{YEAR}} placeholder in reference URLs with current year
currentYear := fmt.Sprintf("%d", time.Now().Year())
for i := range cvData.References {
cvData.References[i].URL = replaceYearPlaceholder(cvData.References[i].URL, currentYear)
}
return &cvData, nil
}
// findDataFile locates a data file by searching up the directory tree
func findDataFile(filename string) (string, error) {
// Try current directory first
if _, err := os.Stat(filename); err == nil {
return filename, nil
}
// Try parent directories (for tests running from subdirectories)
paths := []string{
filename, // Current dir
"../" + filename, // One level up
"../../" + filename, // Two levels up (for tests in internal/handlers)
"../../../" + filename, // Three levels up
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", fmt.Errorf("file not found: %s (searched: current dir, ../, ../../, ../../../)", filename)
}
// replaceYearPlaceholder replaces {{YEAR}} with the current year
func replaceYearPlaceholder(url string, year string) string {
return strings.ReplaceAll(url, "{{YEAR}}", year)
}
+100
View File
@@ -0,0 +1,100 @@
package cv_test
import (
"testing"
"github.com/juanatsap/cv-site/internal/models/cv"
)
func TestLoadCV(t *testing.T) {
tests := []struct {
name string
lang string
wantErr bool
}{
{
name: "English CV",
lang: "en",
wantErr: false,
},
{
name: "Spanish CV",
lang: "es",
wantErr: false,
},
{
name: "Invalid language",
lang: "fr",
wantErr: true,
},
{
name: "Empty language",
lang: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cvData, err := cv.LoadCV(tt.lang)
if (err != nil) != tt.wantErr {
t.Errorf("LoadCV() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if cvData == nil {
t.Error("LoadCV() returned nil CV data")
return
}
// Validate CV has essential data
if cvData.Personal.Name == "" {
t.Error("LoadCV() returned CV with empty name")
}
if len(cvData.Experience) == 0 {
t.Error("LoadCV() returned CV with no experience")
}
if len(cvData.Skills.Technical) == 0 {
t.Error("LoadCV() returned CV with no technical skills")
}
// Validate that year placeholder was replaced
for i, ref := range cvData.References {
if ref.URL == "" {
continue
}
if len(ref.URL) >= 2 && ref.URL[0:2] == "{{" {
t.Errorf("LoadCV() reference %d still has placeholder in URL: %s", i, ref.URL)
}
}
}
})
}
}
func TestLoadCV_DataIntegrity(t *testing.T) {
cvData, err := cv.LoadCV("en")
if err != nil {
t.Fatalf("LoadCV() failed: %v", err)
}
// Test that computed fields are properly initialized (empty, not populated yet)
for i, exp := range cvData.Experience {
if exp.Duration != "" {
t.Errorf("Experience[%d].Duration should be empty before calculation, got: %s", i, exp.Duration)
}
}
// Test that JSON tags are properly used
if cvData.Meta.Version == "" {
t.Error("Meta.Version should not be empty")
}
if cvData.Meta.Language == "" {
t.Error("Meta.Language should not be empty")
}
}
+56
View File
@@ -0,0 +1,56 @@
package ui
import (
"encoding/json"
"fmt"
"os"
)
// LoadUI loads UI translations from a JSON file for the specified language
func LoadUI(lang string) (*UI, error) {
if lang != "en" && lang != "es" {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
filename := fmt.Sprintf("data/ui-%s.json", lang)
filepath, err := findDataFile(filename)
if err != nil {
return nil, err
}
data, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
}
var uiData UI
if err := json.Unmarshal(data, &uiData); err != nil {
return nil, fmt.Errorf("error parsing JSON: %w", err)
}
return &uiData, nil
}
// findDataFile locates a data file by searching up the directory tree
func findDataFile(filename string) (string, error) {
// Try current directory first
if _, err := os.Stat(filename); err == nil {
return filename, nil
}
// Try parent directories (for tests running from subdirectories)
paths := []string{
filename, // Current dir
"../" + filename, // One level up
"../../" + filename, // Two levels up (for tests in internal/handlers)
"../../../" + filename, // Three levels up
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", fmt.Errorf("file not found: %s (searched: current dir, ../, ../../, ../../../)", filename)
}
+98
View File
@@ -0,0 +1,98 @@
package ui_test
import (
"testing"
"github.com/juanatsap/cv-site/internal/models/ui"
)
func TestLoadUI(t *testing.T) {
tests := []struct {
name string
lang string
wantErr bool
}{
{
name: "English UI",
lang: "en",
wantErr: false,
},
{
name: "Spanish UI",
lang: "es",
wantErr: false,
},
{
name: "Invalid language",
lang: "fr",
wantErr: true,
},
{
name: "Empty language",
lang: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uiData, err := ui.LoadUI(tt.lang)
if (err != nil) != tt.wantErr {
t.Errorf("LoadUI() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if uiData == nil {
t.Error("LoadUI() returned nil UI data")
return
}
// Validate UI has essential data
if uiData.InfoModal.Title == "" {
t.Error("LoadUI() returned UI with empty InfoModal title")
}
if uiData.ShortcutsModal.Title == "" {
t.Error("LoadUI() returned UI with empty ShortcutsModal title")
}
// Validate TechStack is populated
if uiData.InfoModal.TechStack.GoHono == "" {
t.Error("LoadUI() returned UI with empty TechStack.GoHono")
}
}
})
}
}
func TestLoadUI_ModalData(t *testing.T) {
uiData, err := ui.LoadUI("en")
if err != nil {
t.Fatalf("LoadUI() failed: %v", err)
}
// Test InfoModal structure
if uiData.InfoModal.Description == "" {
t.Error("InfoModal.Description should not be empty")
}
if uiData.InfoModal.ViewSource == "" {
t.Error("InfoModal.ViewSource should not be empty")
}
// Test ShortcutsModal structure
if uiData.ShortcutsModal.Description == "" {
t.Error("ShortcutsModal.Description should not be empty")
}
// Test that shortcuts sections exist
if uiData.ShortcutsModal.Sections.Zoom.Title == "" {
t.Error("Zoom shortcuts section should have a title")
}
if uiData.ShortcutsModal.Sections.Actions.Title == "" {
t.Error("Actions shortcuts section should have a title")
}
}
+61
View File
@@ -0,0 +1,61 @@
package ui
import "html/template"
// UI represents user interface translations and configuration
type UI struct {
InfoModal InfoModal `json:"infoModal"`
ShortcutsModal ShortcutsModal `json:"shortcutsModal"`
}
type InfoModal struct {
Title string `json:"title"`
Description template.HTML `json:"description"`
TechStack TechStack `json:"techStack"`
ViewSource string `json:"viewSource"`
ViewSourceSubtext string `json:"viewSourceSubtext"`
}
type TechStack struct {
GoHono string `json:"goHono"`
HTMX string `json:"htmx"`
HTML5 string `json:"html5"`
CSS3 string `json:"css3"`
}
type ShortcutsModal struct {
Title string `json:"title"`
Description string `json:"description"`
Sections ShortcutsSections `json:"sections"`
}
type ShortcutsSections struct {
Zoom ShortcutGroup `json:"zoom"`
ViewControls ShortcutGroup `json:"viewControls"`
Navigation ShortcutGroup `json:"navigation"`
Actions ShortcutGroup `json:"actions"`
Browser ShortcutGroup `json:"browser"`
}
type ShortcutGroup struct {
Title string `json:"title"`
ZoomIn *ShortcutItem `json:"zoomIn,omitempty"`
ZoomOut *ShortcutItem `json:"zoomOut,omitempty"`
ZoomReset *ShortcutItem `json:"zoomReset,omitempty"`
ToggleLength *ShortcutItem `json:"toggleLength,omitempty"`
ToggleIcons *ShortcutItem `json:"toggleIcons,omitempty"`
ToggleTheme *ShortcutItem `json:"toggleTheme,omitempty"`
ExpandAll *ShortcutItem `json:"expandAll,omitempty"`
CollapseAll *ShortcutItem `json:"collapseAll,omitempty"`
ScrollToTop *ShortcutItem `json:"scrollToTop,omitempty"`
Print *ShortcutItem `json:"print,omitempty"`
CloseModal *ShortcutItem `json:"closeModal,omitempty"`
ShowHelp *ShortcutItem `json:"showHelp,omitempty"`
Tab *ShortcutItem `json:"tab,omitempty"`
Enter *ShortcutItem `json:"enter,omitempty"`
}
type ShortcutItem struct {
Key string `json:"key"`
Description string `json:"description"`
}