@nakedev/go-scaffold 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +567 -513
  2. package/dist/commands/auth.js +12 -1
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/create.js +2 -1
  5. package/dist/commands/generate.js +5 -2
  6. package/dist/commands/method.js +66 -70
  7. package/dist/commands/observability.js +4 -53
  8. package/dist/commands/rbac.js +19 -8
  9. package/dist/commands/undo.js +6 -3
  10. package/dist/commands/worker.js +14 -4
  11. package/dist/index.js +13 -1
  12. package/dist/templates/auth-manifest.js +46 -45
  13. package/dist/templates/create-manifest.js +4 -0
  14. package/dist/templates/module-manifest.js +82 -60
  15. package/dist/templates/rbac-manifest.js +15 -11
  16. package/dist/templates/worker-manifest.js +4 -1
  17. package/dist/types.js +2 -0
  18. package/dist/utils/auth-patcher.js +22 -22
  19. package/dist/utils/config.js +24 -5
  20. package/dist/utils/docs-patcher.js +68 -0
  21. package/dist/utils/hexagonal-method-patcher.js +334 -0
  22. package/dist/utils/main-patcher.js +3 -3
  23. package/dist/utils/module-location.js +17 -11
  24. package/dist/utils/platform-patcher.js +27 -0
  25. package/dist/utils/rbac-patcher.js +73 -216
  26. package/package.json +1 -1
  27. package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
  28. package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
  29. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  30. package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
  31. package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
  32. package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
  33. package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
  34. package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
  35. package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
  36. package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
  37. package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
  38. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  39. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  40. package/templates/add/auth/internal/app/user/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
  41. package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
  42. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  43. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  44. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  45. package/templates/add/auth/internal/app/user/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
  46. package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
  47. package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
  48. package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
  49. package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
  50. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  51. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  52. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  53. package/templates/add/auth/internal/app/user/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
  54. package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
  55. package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
  56. package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
  57. package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
  58. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
  59. package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
  60. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
  61. package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
  62. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  63. package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
  64. package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
  65. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  66. package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
  67. package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
  68. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  69. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  70. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  71. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  72. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +1 -1
  73. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  74. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
  75. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  76. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  77. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  78. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  79. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  80. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  81. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  82. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  83. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  84. package/templates/add/rbac/internal/app/role/composition.go.hbs +23 -10
  85. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  86. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  87. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  88. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  89. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  90. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  91. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +54 -36
  92. package/templates/create/base/.golangci.yml.hbs +2 -2
  93. package/templates/create/base/AGENTS.md.hbs +55 -31
  94. package/templates/create/base/README.md.hbs +77 -22
  95. package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
  96. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  97. package/templates/create/features/docs/architecture.md.hbs +64 -26
  98. package/templates/create/features/docs/patterns.md.hbs +117 -90
  99. package/templates/create/features/docs/techstack.md.hbs +17 -2
  100. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  101. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  102. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  103. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  104. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  105. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  106. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  107. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  108. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  109. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  110. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  111. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  112. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  113. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  114. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  115. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  116. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  117. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  118. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  119. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  120. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  121. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  122. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  123. package/templates/generate/module/migration.up.sql.hbs +1 -1
  124. package/dist/utils/method-patcher.js +0 -537
  125. package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
  126. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
  127. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
  128. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
  129. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
  130. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  131. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
  132. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
  133. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
  134. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
  135. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
  136. package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
  137. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
  138. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  139. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  140. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  141. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  142. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  143. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  144. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  145. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  146. package/templates/generate/module/commands.go.hbs +0 -95
  147. package/templates/generate/module/composition.go.hbs +0 -23
  148. package/templates/generate/module/cqrs_test.go.hbs +0 -7
  149. package/templates/generate/module/dto.go.hbs +0 -36
  150. package/templates/generate/module/errors.go.hbs +0 -33
  151. package/templates/generate/module/handler.go.hbs +0 -179
  152. package/templates/generate/module/handler_test.go.hbs +0 -174
  153. package/templates/generate/module/minimal/commands.go.hbs +0 -34
  154. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  155. package/templates/generate/module/minimal/handler.go.hbs +0 -82
  156. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  157. package/templates/generate/module/minimal/queries.go.hbs +0 -45
  158. package/templates/generate/module/minimal/service.go.hbs +0 -71
  159. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  160. package/templates/generate/module/model/model.go.hbs +0 -36
  161. package/templates/generate/module/queries.go.hbs +0 -62
  162. package/templates/generate/module/repository.go.hbs +0 -103
  163. package/templates/generate/module/service.go.hbs +0 -164
  164. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,127 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "{{goModule}}/internal/app/role/domain"
8
+ "{{goModule}}/internal/app/role/ports"
9
+ "{{goModule}}/internal/shared/dberr"
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ var _ ports.Repository = (*Repository)(nil)
16
+
17
+ type Repository struct {
18
+ db *gorm.DB
19
+ }
20
+
21
+ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
22
+
23
+ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
24
+ var codes []string
25
+ err := tx.From(ctx, r.db).WithContext(ctx).
26
+ Model(&RolePermission{}).Where("role_code = ?", roleCode).Pluck("permission_code", &codes).Error
27
+ return codes, mapDatabaseError(err)
28
+ }
29
+
30
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.Role, error) {
31
+ var rows []Role
32
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
33
+ if err != nil {
34
+ return nil, mapDatabaseError(err)
35
+ }
36
+ items := make([]domain.Role, len(rows))
37
+ for i := range rows {
38
+ items[i] = toDomainRole(&rows[i])
39
+ }
40
+ return items, nil
41
+ }
42
+
43
+ func (r *Repository) FindByCode(ctx context.Context, code string) (*domain.Role, error) {
44
+ var row Role
45
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "code = ?", code).Error; err != nil {
46
+ return nil, mapDatabaseError(err)
47
+ }
48
+ value := toDomainRole(&row)
49
+ return &value, nil
50
+ }
51
+
52
+ func (r *Repository) Create(ctx context.Context, role *domain.Role) error {
53
+ return mapDatabaseError(tx.From(ctx, r.db).WithContext(ctx).Create(fromDomainRole(role)).Error)
54
+ }
55
+
56
+ func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
57
+ return tx.Do(ctx, r.db, func(txctx context.Context) error {
58
+ db := tx.From(txctx, r.db).WithContext(txctx)
59
+ if err := db.Where("role_code = ?", roleCode).Delete(&RolePermission{}).Error; err != nil {
60
+ return mapDatabaseError(err)
61
+ }
62
+ if len(permissionCodes) == 0 {
63
+ return nil
64
+ }
65
+ grants := make([]RolePermission, len(permissionCodes))
66
+ for i, code := range permissionCodes {
67
+ grants[i] = RolePermission{RoleCode: roleCode, PermissionCode: code}
68
+ }
69
+ return mapDatabaseError(db.Create(&grants).Error)
70
+ })
71
+ }
72
+
73
+ func (r *Repository) Delete(ctx context.Context, code string) error {
74
+ result := tx.From(ctx, r.db).WithContext(ctx).Where("code = ?", code).Delete(&Role{})
75
+ if result.Error != nil {
76
+ if dberr.IsForeignKey(result.Error) {
77
+ return domain.ErrHasReferences
78
+ }
79
+ return mapDatabaseError(result.Error)
80
+ }
81
+ if result.RowsAffected == 0 {
82
+ return domain.ErrNotFound
83
+ }
84
+ return nil
85
+ }
86
+
87
+ func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]domain.Permission, error) {
88
+ var rows []Permission
89
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
90
+ if err != nil {
91
+ return nil, mapDatabaseError(err)
92
+ }
93
+ items := make([]domain.Permission, len(rows))
94
+ for i := range rows {
95
+ items[i] = toDomainPermission(&rows[i])
96
+ }
97
+ return items, nil
98
+ }
99
+
100
+ func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
101
+ var codes []string
102
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&Permission{}).Pluck("code", &codes).Error
103
+ return codes, mapDatabaseError(err)
104
+ }
105
+
106
+ func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
107
+ var count int64
108
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&RolePermission{}).
109
+ Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).Count(&count).Error
110
+ return count, mapDatabaseError(err)
111
+ }
112
+
113
+ func mapDatabaseError(err error) error {
114
+ if err == nil {
115
+ return nil
116
+ }
117
+ if errors.Is(err, gorm.ErrRecordNotFound) {
118
+ return domain.ErrNotFound
119
+ }
120
+ if dberr.IsDuplicate(err) {
121
+ return domain.ErrConflict
122
+ }
123
+ if dberr.IsForeignKey(err) {
124
+ return domain.ErrUnknownPermission
125
+ }
126
+ return err
127
+ }
@@ -1,11 +1,11 @@
1
- package role
1
+ package postgres
2
2
 
3
3
  import (
4
4
  "context"
5
5
  "os"
6
6
  "testing"
7
7
 
8
- "{{goModule}}/internal/app/role/model"
8
+ "{{goModule}}/internal/app/role/domain"
9
9
 
10
10
  "github.com/google/uuid"
11
11
  "gorm.io/driver/postgres"
@@ -67,11 +67,11 @@ func testCode(t *testing.T) string {
67
67
  }
68
68
 
69
69
  // seedPermission inserts directly rather than through Repository, which has
70
- // no create method for Permission on purpose (see model/permission.go.hbs:
70
+ // no create method for Permission on purpose (see adapters/outbound/postgres/model.go:
71
71
  // "seeded by migration, not admin-created"). Tests still need rows to grant.
72
72
  func seedPermission(t *testing.T, tx *gorm.DB, code string) {
73
73
  t.Helper()
74
- if err := tx.WithContext(context.Background()).Create(&model.Permission{Code: code, Description: code}).Error; err != nil {
74
+ if err := tx.WithContext(context.Background()).Create(&Permission{Code: code, Description: code}).Error; err != nil {
75
75
  t.Fatalf("seed permission %s: %v", code, err)
76
76
  }
77
77
  }
@@ -87,7 +87,7 @@ func TestRepository_SetPermissions_ReplacesEntireSet(t *testing.T) {
87
87
 
88
88
  roleCode := testCode(t)
89
89
  permA, permB, permC := testCode(t), testCode(t), testCode(t)
90
- if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
90
+ if err := repo.Create(ctx, &domain.Role{Code: roleCode, Name: roleCode}); err != nil {
91
91
  t.Fatalf("seed role: %v", err)
92
92
  }
93
93
  seedPermission(t, tx, permA)
@@ -121,7 +121,7 @@ func TestRepository_Delete_CascadesRolePermissions(t *testing.T) {
121
121
 
122
122
  roleCode := testCode(t)
123
123
  permCode := testCode(t)
124
- if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
124
+ if err := repo.Create(ctx, &domain.Role{Code: roleCode, Name: roleCode}); err != nil {
125
125
  t.Fatalf("seed role: %v", err)
126
126
  }
127
127
  seedPermission(t, tx, permCode)
@@ -152,10 +152,10 @@ func TestRepository_CountRolesWithPermission_ExcludesGivenRole(t *testing.T) {
152
152
 
153
153
  roleA, roleB := testCode(t), testCode(t)
154
154
  permCode := testCode(t)
155
- if err := repo.Create(ctx, &model.Role{Code: roleA, Name: roleA}); err != nil {
155
+ if err := repo.Create(ctx, &domain.Role{Code: roleA, Name: roleA}); err != nil {
156
156
  t.Fatalf("seed role a: %v", err)
157
157
  }
158
- if err := repo.Create(ctx, &model.Role{Code: roleB, Name: roleB}); err != nil {
158
+ if err := repo.Create(ctx, &domain.Role{Code: roleB, Name: roleB}); err != nil {
159
159
  t.Fatalf("seed role b: %v", err)
160
160
  }
161
161
  seedPermission(t, tx, permCode)
@@ -0,0 +1,47 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/role/domain"
7
+ )
8
+
9
+ const PermRoleManage = "role:manage"
10
+
11
+ type CreateInput struct {
12
+ Code string `json:"code"`
13
+ Name string `json:"name"`
14
+ }
15
+
16
+ type SetPermissionsInput struct {
17
+ PermissionCodes *[]string `json:"permission_codes"`
18
+ }
19
+
20
+ type RoleListItem struct {
21
+ Role domain.Role
22
+ Permissions []string
23
+ }
24
+
25
+ type RoleResponse struct {
26
+ Code string `json:"code"`
27
+ Name string `json:"name"`
28
+ IsSystem bool `json:"is_system"`
29
+ Permissions []string `json:"permissions"`
30
+ CreatedAt time.Time `json:"created_at"`
31
+ }
32
+
33
+ type PermissionResponse struct {
34
+ Code string `json:"code"`
35
+ Description string `json:"description"`
36
+ }
37
+
38
+ func ToRoleResponse(item RoleListItem) RoleResponse {
39
+ return RoleResponse{
40
+ Code: item.Role.Code, Name: item.Role.Name, IsSystem: item.Role.IsSystem,
41
+ Permissions: item.Permissions, CreatedAt: item.Role.CreatedAt,
42
+ }
43
+ }
44
+
45
+ func ToPermissionResponse(item domain.Permission) PermissionResponse {
46
+ return PermissionResponse{Code: item.Code, Description: item.Description}
47
+ }
@@ -0,0 +1,19 @@
1
+ package application
2
+
3
+ import "{{goModule}}/internal/app/role/domain"
4
+
5
+ func errInvalidCode() error {
6
+ return domain.Rule("ROLE_INVALID_CODE", "code must start with a lowercase letter and contain only lowercase letters, digits, or underscores", domain.ErrInvalidCode)
7
+ }
8
+
9
+ func errSystemRole() error {
10
+ return domain.Rule("ROLE_IS_SYSTEM", "staff/admin are system roles and cannot be deleted", domain.ErrSystemRole)
11
+ }
12
+
13
+ func errUnknownPermission() error {
14
+ return domain.Rule("ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist", domain.ErrUnknownPermission)
15
+ }
16
+
17
+ func errLastRoleManager() error {
18
+ return domain.Rule("ROLE_LAST_MANAGER", "this is the only role that can manage roles/permissions — grant "+PermRoleManage+" to another role first", domain.ErrLastRoleManager)
19
+ }
@@ -0,0 +1,157 @@
1
+ // Package application contains role management use cases. It owns the rules
2
+ // for permission grants and never imports GORM, Gin, or HTTP error types.
3
+ package application
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "regexp"
9
+ "slices"
10
+
11
+ "{{goModule}}/internal/app/role/domain"
12
+ "{{goModule}}/internal/app/role/ports"
13
+ )
14
+
15
+ type ServicePort interface {
16
+ PermissionsOf(context.Context, string) (map[string]struct{}, error)
17
+ CodeExists(context.Context, string) (bool, error)
18
+ List(context.Context, int, int) ([]RoleListItem, error)
19
+ Create(context.Context, CreateInput) (*RoleListItem, error)
20
+ SetPermissions(context.Context, string, []string) (*RoleListItem, error)
21
+ Delete(context.Context, string) error
22
+ ListPermissions(context.Context, int, int) ([]domain.Permission, error)
23
+ }
24
+
25
+ type Service struct {
26
+ repo ports.Repository
27
+ }
28
+
29
+ func NewService(repo ports.Repository) *Service { return &Service{repo: repo} }
30
+
31
+ func (s *Service) PermissionsOf(ctx context.Context, roleCode string) (map[string]struct{}, error) {
32
+ codes, err := s.repo.PermissionCodes(ctx, roleCode)
33
+ if err != nil {
34
+ return nil, err
35
+ }
36
+ set := make(map[string]struct{}, len(codes))
37
+ for _, code := range codes {
38
+ set[code] = struct{}{}
39
+ }
40
+ return set, nil
41
+ }
42
+
43
+ func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
44
+ if _, err := s.repo.FindByCode(ctx, code); err != nil {
45
+ if errors.Is(err, domain.ErrNotFound) {
46
+ return false, nil
47
+ }
48
+ return false, err
49
+ }
50
+ return true, nil
51
+ }
52
+
53
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]RoleListItem, error) {
54
+ roles, err := s.repo.FindAll(ctx, limit, offset)
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+ out := make([]RoleListItem, len(roles))
59
+ for i, role := range roles {
60
+ perms, err := s.repo.PermissionCodes(ctx, role.Code)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ out[i] = RoleListItem{Role: role, Permissions: perms}
65
+ }
66
+ return out, nil
67
+ }
68
+
69
+ var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
70
+
71
+ func (s *Service) Create(ctx context.Context, in CreateInput) (*RoleListItem, error) {
72
+ if !roleCodePattern.MatchString(in.Code) {
73
+ return nil, errInvalidCode()
74
+ }
75
+ role := &domain.Role{Code: in.Code, Name: in.Name}
76
+ if err := s.repo.Create(ctx, role); err != nil {
77
+ return nil, err
78
+ }
79
+ return &RoleListItem{Role: *role, Permissions: []string{}}, nil
80
+ }
81
+
82
+ func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*RoleListItem, error) {
83
+ role, err := s.repo.FindByCode(ctx, code)
84
+ if err != nil {
85
+ return nil, wrapFindErr(err)
86
+ }
87
+ if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
88
+ return nil, err
89
+ }
90
+ if !slices.Contains(permissionCodes, PermRoleManage) {
91
+ isLast, err := s.isLastRoleManager(ctx, code)
92
+ if err != nil {
93
+ return nil, err
94
+ }
95
+ if isLast {
96
+ return nil, errLastRoleManager()
97
+ }
98
+ }
99
+ if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
100
+ return nil, err
101
+ }
102
+ return &RoleListItem{Role: *role, Permissions: permissionCodes}, nil
103
+ }
104
+
105
+ func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
106
+ all, err := s.repo.AllPermissionCodes(ctx)
107
+ if err != nil {
108
+ return err
109
+ }
110
+ valid := make(map[string]struct{}, len(all))
111
+ for _, code := range all {
112
+ valid[code] = struct{}{}
113
+ }
114
+ for _, code := range codes {
115
+ if _, ok := valid[code]; !ok {
116
+ return errUnknownPermission()
117
+ }
118
+ }
119
+ return nil
120
+ }
121
+
122
+ func (s *Service) Delete(ctx context.Context, code string) error {
123
+ role, err := s.repo.FindByCode(ctx, code)
124
+ if err != nil {
125
+ return wrapFindErr(err)
126
+ }
127
+ if role.IsSystem {
128
+ return errSystemRole()
129
+ }
130
+ isLast, err := s.isLastRoleManager(ctx, code)
131
+ if err != nil {
132
+ return err
133
+ }
134
+ if isLast {
135
+ return errLastRoleManager()
136
+ }
137
+ return s.repo.Delete(ctx, code)
138
+ }
139
+
140
+ func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
141
+ others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
142
+ if err != nil {
143
+ return false, err
144
+ }
145
+ return others == 0, nil
146
+ }
147
+
148
+ func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]domain.Permission, error) {
149
+ return s.repo.FindAllPermissions(ctx, limit, offset)
150
+ }
151
+
152
+ func wrapFindErr(err error) error {
153
+ if errors.Is(err, domain.ErrNotFound) {
154
+ return domain.ErrNotFound
155
+ }
156
+ return err
157
+ }
@@ -1,4 +1,4 @@
1
- package role
1
+ package application
2
2
 
3
3
  import (
4
4
  "context"
@@ -6,8 +6,7 @@ import (
6
6
  "net/http"
7
7
  "testing"
8
8
 
9
- "{{goModule}}/internal/app/role/model"
10
- "{{goModule}}/internal/shared/apperror"
9
+ "{{goModule}}/internal/app/role/domain"
11
10
  )
12
11
 
13
12
  // fakeRepo = mock of the repository interface, so the service can be tested
@@ -15,7 +14,7 @@ import (
15
14
  // than the one being edited currently grant PermRoleManage", exactly what
16
15
  // CountRolesWithPermission's excludeCode param means.
17
16
  type fakeRepo struct {
18
- role *model.Role
17
+ role *domain.Role
19
18
  findErr error
20
19
  allPermissionCodes []string
21
20
  countWithPermission int64
@@ -24,38 +23,46 @@ type fakeRepo struct {
24
23
  }
25
24
 
26
25
  func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
27
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.Role, error) { return nil, nil }
28
- func (f *fakeRepo) FindByCode(context.Context, string) (*model.Role, error) {
26
+ func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.Role, error) { return nil, nil }
27
+ func (f *fakeRepo) FindByCode(context.Context, string) (*domain.Role, error) {
29
28
  if f.findErr != nil {
30
29
  return nil, f.findErr
31
30
  }
32
31
  return f.role, nil
33
32
  }
34
- func (f *fakeRepo) Create(context.Context, *model.Role) error { return nil }
33
+ func (f *fakeRepo) Create(context.Context, *domain.Role) error { return nil }
35
34
  func (f *fakeRepo) SetPermissions(context.Context, string, []string) error {
36
35
  return f.setPermissionsErr
37
36
  }
38
37
  func (f *fakeRepo) Delete(context.Context, string) error { return f.deleteErr }
39
- func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]model.Permission, error) {
38
+ func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]domain.Permission, error) {
40
39
  return nil, nil
41
40
  }
42
- func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) { return f.allPermissionCodes, nil }
41
+ func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) {
42
+ return f.allPermissionCodes, nil
43
+ }
43
44
  func (f *fakeRepo) CountRolesWithPermission(context.Context, string, string) (int64, error) {
44
45
  return f.countWithPermission, nil
45
46
  }
46
47
 
47
48
  func status(t *testing.T, err error) int {
48
49
  t.Helper()
49
- var appErr *apperror.AppError
50
- if !errors.As(err, &appErr) {
51
- t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
50
+ if errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrSystemRole) || errors.Is(err, domain.ErrLastRoleManager) {
51
+ return http.StatusConflict
52
+ }
53
+ if errors.Is(err, domain.ErrInvalidCode) || errors.Is(err, domain.ErrUnknownPermission) {
54
+ return http.StatusUnprocessableEntity
55
+ }
56
+ if errors.Is(err, domain.ErrNotFound) {
57
+ return http.StatusNotFound
52
58
  }
53
- return appErr.HTTPStatus
59
+ t.Fatalf("unexpected role error %T: %v", err, err)
60
+ return 0
54
61
  }
55
62
 
56
63
  func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
57
64
  repo := &fakeRepo{
58
- role: &model.Role{Code: "ops", Name: "Ops"},
65
+ role: &domain.Role{Code: "ops", Name: "Ops"},
59
66
  allPermissionCodes: []string{PermRoleManage, "user:read"},
60
67
  countWithPermission: 0, // no OTHER role grants PermRoleManage
61
68
  }
@@ -69,7 +76,7 @@ func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
69
76
 
70
77
  func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T) {
71
78
  repo := &fakeRepo{
72
- role: &model.Role{Code: "ops", Name: "Ops"},
79
+ role: &domain.Role{Code: "ops", Name: "Ops"},
73
80
  allPermissionCodes: []string{PermRoleManage, "user:read"},
74
81
  countWithPermission: 1, // another role still grants PermRoleManage
75
82
  }
@@ -81,7 +88,7 @@ func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T)
81
88
 
82
89
  func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
83
90
  repo := &fakeRepo{
84
- role: &model.Role{Code: "ops", Name: "Ops"},
91
+ role: &domain.Role{Code: "ops", Name: "Ops"},
85
92
  allPermissionCodes: []string{PermRoleManage},
86
93
  countWithPermission: 0,
87
94
  }
@@ -93,7 +100,7 @@ func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
93
100
  }
94
101
 
95
102
  func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
96
- repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 0}
103
+ repo := &fakeRepo{role: &domain.Role{Code: "ops"}, countWithPermission: 0}
97
104
  svc := NewService(repo)
98
105
  err := svc.Delete(context.Background(), "ops")
99
106
  if got := status(t, err); got != http.StatusConflict {
@@ -102,7 +109,7 @@ func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
102
109
  }
103
110
 
104
111
  func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
105
- repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 1}
112
+ repo := &fakeRepo{role: &domain.Role{Code: "ops"}, countWithPermission: 1}
106
113
  svc := NewService(repo)
107
114
  if err := svc.Delete(context.Background(), "ops"); err != nil {
108
115
  t.Fatalf("expected delete to succeed when another role still manages roles, got %v", err)
@@ -110,7 +117,7 @@ func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
110
117
  }
111
118
 
112
119
  func TestService_Delete_SystemRoleRejected(t *testing.T) {
113
- repo := &fakeRepo{role: &model.Role{Code: "admin", IsSystem: true}}
120
+ repo := &fakeRepo{role: &domain.Role{Code: "admin", IsSystem: true}}
114
121
  svc := NewService(repo)
115
122
  err := svc.Delete(context.Background(), "admin")
116
123
  if got := status(t, err); got != http.StatusConflict {
@@ -3,33 +3,46 @@ package role
3
3
  import (
4
4
  "time"
5
5
 
6
+ httpadapter "{{goModule}}/internal/app/role/adapters/inbound/http"
7
+ "{{goModule}}/internal/app/role/adapters/outbound/postgres"
8
+ "{{goModule}}/internal/app/role/application"
9
+ "{{goModule}}/internal/app/role/domain"
10
+ "{{goModule}}/internal/app/role/ports"
6
11
  "{{goModule}}/internal/shared/middleware"
7
12
 
8
13
  "gorm.io/gorm"
9
14
  )
10
15
 
11
- // Composition groups role's service, authorization adapter, and HTTP handler
12
- // so cmd/api can register the feature without constructing its internals.
16
+ // Composition is the only public graph exposed to cmd/api. The feature root
17
+ // owns the role repository, application service, permission cache, and HTTP
18
+ // adapter; process wiring does not construct any of those internals.
13
19
  type Composition struct {
14
- Service *Service
20
+ Service *application.Service
15
21
  Authz *middleware.Authz
16
- Handler *Handler
22
+ Handler *httpadapter.Handler
17
23
  }
18
24
 
19
- // NewServiceFromDB is the role feature's persistence composition boundary.
25
+ type Service = application.Service
26
+ type ServicePort = application.ServicePort
27
+ type Repository = postgres.Repository
28
+ type CreateInput = application.CreateInput
29
+ type SetPermissionsInput = application.SetPermissionsInput
30
+ type Role = domain.Role
31
+ type Permission = domain.Permission
32
+ type AuthorizationPort = ports.AuthorizationPort
33
+
34
+ const PermRoleManage = application.PermRoleManage
35
+
20
36
  func NewServiceFromDB(db *gorm.DB) *Service {
21
- return NewService(NewRepository(db))
37
+ return application.NewService(postgres.NewRepository(db))
22
38
  }
23
39
 
24
- // NewCompositionFromDB builds role and its authorization middleware locally.
25
- // The API binary supplies the shared DB/config values and only registers the
26
- // returned handlers.
27
40
  func NewCompositionFromDB(db *gorm.DB, jwtSecret string, cacheTTL time.Duration) *Composition {
28
41
  service := NewServiceFromDB(db)
29
42
  authz := middleware.NewAuthz(service.PermissionsOf, cacheTTL)
30
43
  return &Composition{
31
44
  Service: service,
32
45
  Authz: authz,
33
- Handler: NewHandler(service, jwtSecret, authz),
46
+ Handler: httpadapter.NewHandler(service, jwtSecret, authz),
34
47
  }
35
48
  }
@@ -0,0 +1,23 @@
1
+ // Package domain contains role and permission state without GORM or HTTP
2
+ // concerns.
3
+ package domain
4
+
5
+ import "time"
6
+
7
+ type Role struct {
8
+ Code string
9
+ Name string
10
+ IsSystem bool
11
+ CreatedAt time.Time
12
+ UpdatedAt time.Time
13
+ }
14
+
15
+ type Permission struct {
16
+ Code string
17
+ Description string
18
+ }
19
+
20
+ type RolePermission struct {
21
+ RoleCode string
22
+ PermissionCode string
23
+ }
@@ -0,0 +1,26 @@
1
+ package domain
2
+
3
+ import "errors"
4
+
5
+ var (
6
+ ErrNotFound = errors.New("role not found")
7
+ ErrConflict = errors.New("role conflict")
8
+ ErrHasReferences = errors.New("role has references")
9
+ ErrInvalidCode = errors.New("role code is invalid")
10
+ ErrSystemRole = errors.New("system role cannot be changed")
11
+ ErrUnknownPermission = errors.New("permission does not exist")
12
+ ErrLastRoleManager = errors.New("last role manager cannot be removed")
13
+ )
14
+
15
+ type RuleError struct {
16
+ Code string
17
+ Message string
18
+ Cause error
19
+ }
20
+
21
+ func (e *RuleError) Error() string { return e.Message }
22
+ func (e *RuleError) Unwrap() error { return e.Cause }
23
+
24
+ func Rule(code, message string, cause error) error {
25
+ return &RuleError{Code: code, Message: message, Cause: cause}
26
+ }
@@ -0,0 +1,25 @@
1
+ // Package ports contains role's application-owned repository contracts.
2
+ package ports
3
+
4
+ import (
5
+ "context"
6
+
7
+ "{{goModule}}/internal/app/role/domain"
8
+ )
9
+
10
+ type Repository interface {
11
+ PermissionCodes(context.Context, string) ([]string, error)
12
+ FindAll(context.Context, int, int) ([]domain.Role, error)
13
+ FindByCode(context.Context, string) (*domain.Role, error)
14
+ Create(context.Context, *domain.Role) error
15
+ SetPermissions(context.Context, string, []string) error
16
+ Delete(context.Context, string) error
17
+ FindAllPermissions(context.Context, int, int) ([]domain.Permission, error)
18
+ AllPermissionCodes(context.Context) ([]string, error)
19
+ CountRolesWithPermission(context.Context, string, string) (int64, error)
20
+ }
21
+
22
+ type AuthorizationPort interface {
23
+ PermissionsOf(context.Context, string) (map[string]struct{}, error)
24
+ CodeExists(context.Context, string) (bool, error)
25
+ }