@nakedev/go-scaffold 0.4.0 → 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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,39 +0,0 @@
1
- package role
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/apperror"
7
- )
8
-
9
- // error catalog specific to role — codes carry more meaning than the generic apperror ones
10
- // functions, not vars: the error middleware writes RequestID onto the returned pointer directly
11
- // (a shared instance would race across concurrent requests).
12
-
13
- func errNotFound() *apperror.AppError {
14
- return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
15
- }
16
-
17
- func errConflict() *apperror.AppError {
18
- return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role code already exists")
19
- }
20
-
21
- func errHasReferences() *apperror.AppError {
22
- return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
23
- }
24
-
25
- func errInvalidCode() *apperror.AppError {
26
- return apperror.New(http.StatusUnprocessableEntity, "ROLE_INVALID_CODE", "code must start with a lowercase letter and contain only lowercase letters, digits, or underscores")
27
- }
28
-
29
- func errSystemRole() *apperror.AppError {
30
- return apperror.New(http.StatusConflict, "ROLE_IS_SYSTEM", "staff/admin are system roles and cannot be deleted")
31
- }
32
-
33
- func errUnknownPermission() *apperror.AppError {
34
- return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
35
- }
36
-
37
- func errLastRoleManager() *apperror.AppError {
38
- return apperror.New(http.StatusConflict, "ROLE_LAST_MANAGER", "this is the only role that can manage roles/permissions — grant "+PermRoleManage+" to another role first")
39
- }
@@ -1,104 +0,0 @@
1
- package role
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/httpx"
7
- "{{goModule}}/internal/shared/middleware"
8
- "{{goModule}}/internal/shared/pagination"
9
-
10
- "github.com/gin-gonic/gin"
11
- )
12
-
13
- // PermRoleManage gates every endpoint in this file — role/permission
14
- // management is a single admin capability, not split per-action.
15
- const PermRoleManage = "role:manage"
16
-
17
- type Handler struct {
18
- svc *Service
19
- jwtSecret string
20
- authz *middleware.Authz
21
- }
22
-
23
- func NewHandler(svc *Service, jwtSecret string, authz *middleware.Authz) *Handler {
24
- return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
25
- }
26
-
27
- // Register wires role's routes onto the router group (takes an IRouter so it can be nested under /v1)
28
- func (h *Handler) Register(rg gin.IRouter) {
29
- roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(PermRoleManage))
30
- roles.GET("", h.list)
31
- roles.POST("", h.create)
32
- roles.PATCH("/:code/permissions", h.setPermissions)
33
- roles.DELETE("/:code", h.delete)
34
-
35
- perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(PermRoleManage))
36
- perms.GET("", h.listPermissions)
37
- }
38
-
39
- func (h *Handler) list(c *gin.Context) {
40
- p := pagination.Parse(c)
41
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
42
- if err != nil {
43
- c.Error(err)
44
- return
45
- }
46
- out := make([]response, len(items))
47
- for i := range items {
48
- out[i] = toResponse(&items[i].Role, items[i].Permissions)
49
- }
50
- c.JSON(http.StatusOK, p.Response(out))
51
- }
52
-
53
- func (h *Handler) create(c *gin.Context) {
54
- var in CreateInput
55
- if err := c.ShouldBindJSON(&in); err != nil {
56
- c.Error(httpx.BindErr(err))
57
- return
58
- }
59
- item, err := h.svc.Create(c.Request.Context(), in)
60
- if err != nil {
61
- c.Error(err)
62
- return
63
- }
64
- c.JSON(http.StatusCreated, toResponse(&item.Role, item.Permissions))
65
- }
66
-
67
- func (h *Handler) setPermissions(c *gin.Context) {
68
- var in SetPermissionsInput
69
- if err := c.ShouldBindJSON(&in); err != nil {
70
- c.Error(httpx.BindErr(err))
71
- return
72
- }
73
- item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
74
- if err != nil {
75
- c.Error(err)
76
- return
77
- }
78
- // the grant is useless until the cache that answers Require() knows about it
79
- h.authz.Invalidate(item.Code)
80
- c.JSON(http.StatusOK, toResponse(&item.Role, item.Permissions))
81
- }
82
-
83
- func (h *Handler) delete(c *gin.Context) {
84
- if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
85
- c.Error(err)
86
- return
87
- }
88
- h.authz.Invalidate(c.Param("code"))
89
- c.Status(http.StatusNoContent)
90
- }
91
-
92
- func (h *Handler) listPermissions(c *gin.Context) {
93
- p := pagination.Parse(c)
94
- items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
95
- if err != nil {
96
- c.Error(err)
97
- return
98
- }
99
- out := make([]permissionResponse, len(items))
100
- for i := range items {
101
- out[i] = toPermissionResponse(&items[i])
102
- }
103
- c.JSON(http.StatusOK, p.Response(out))
104
- }
@@ -1,12 +0,0 @@
1
- package model
2
-
3
- // Permission = an action middleware.Authz.Require checks for. Rows are
4
- // seeded by migration, not admin-created — a permission with no matching
5
- // Require() call in the code would do nothing.
6
- type Permission struct {
7
- Code string `json:"code" gorm:"type:varchar(50);primaryKey"`
8
- Description string `json:"description" gorm:"not null"`
9
- }
10
-
11
- // TableName — see Role.TableName; same schema, same reasoning.
12
- func (Permission) TableName() string { return "role_svc.permissions" }
@@ -1,22 +0,0 @@
1
- // Package model holds this domain's GORM tables — a folder (not a single
2
- // file) so a domain with more than one table can add a file per table
3
- // without cluttering the domain package's top level.
4
- package model
5
-
6
- import "time"
7
-
8
- // Role = an assignable permission level, backed by a real table instead of a
9
- // hardcoded enum — adding one is a row insert, not a migration + redeploy.
10
- // Code is the primary key (not a generated UUID): users.role already stores
11
- // this string, so using it as the join key needs zero data migration.
12
- type Role struct {
13
- Code string `json:"code" gorm:"type:varchar(20);primaryKey"`
14
- Name string `json:"name" gorm:"not null"`
15
- IsSystem bool `json:"is_system" gorm:"not null;default:false"`
16
- CreatedAt time.Time `json:"created_at"`
17
- UpdatedAt time.Time `json:"updated_at"`
18
- }
19
-
20
- // TableName pins this to role_svc rather than GORM's default inflection —
21
- // every domain gets its own schema, see docs/architect/patterns.md.
22
- func (Role) TableName() string { return "role_svc.roles" }
@@ -1,11 +0,0 @@
1
- package model
2
-
3
- // RolePermission = the grant between Role and Permission — the one thing
4
- // admins actually edit; Role and Permission rows themselves stay fixed.
5
- type RolePermission struct {
6
- RoleCode string `json:"role_code" gorm:"primaryKey;type:varchar(20)"`
7
- PermissionCode string `json:"permission_code" gorm:"primaryKey;type:varchar(50)"`
8
- }
9
-
10
- // TableName — see Role.TableName; same schema, same reasoning.
11
- func (RolePermission) TableName() string { return "role_svc.role_permissions" }
@@ -1,97 +0,0 @@
1
- package role
2
-
3
- import (
4
- "context"
5
-
6
- "{{goModule}}/internal/app/role/model"
7
- "{{goModule}}/internal/shared/tx"
8
-
9
- "gorm.io/gorm"
10
- )
11
-
12
- // Repository = data access for role (the only place that touches the DB for this domain)
13
- // every method takes ctx, so a cancelled request cancels the query too
14
- type Repository struct {
15
- db *gorm.DB
16
- }
17
-
18
- func NewRepository(db *gorm.DB) *Repository {
19
- return &Repository{db: db}
20
- }
21
-
22
- // PermissionCodes returns the permission codes granted to roleCode.
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(&model.RolePermission{}).
27
- Where("role_code = ?", roleCode).
28
- Pluck("permission_code", &codes).Error
29
- return codes, err
30
- }
31
-
32
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.Role, error) {
33
- var items []model.Role
34
- err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
35
- return items, err
36
- }
37
-
38
- func (r *Repository) FindByCode(ctx context.Context, code string) (*model.Role, error) {
39
- var m model.Role
40
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
41
- return nil, err
42
- }
43
- return &m, nil
44
- }
45
-
46
- func (r *Repository) Create(ctx context.Context, m *model.Role) error {
47
- return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
48
- }
49
-
50
- // SetPermissions replaces roleCode's entire permission grant set in one
51
- // transaction — simpler for a checkbox-style admin UI than diffing
52
- // add/remove, and cheap at this scale (a handful of rows per role).
53
- func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
54
- return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
55
- if err := t.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
56
- return err
57
- }
58
- if len(permissionCodes) == 0 {
59
- return nil
60
- }
61
- grants := make([]model.RolePermission, len(permissionCodes))
62
- for i, code := range permissionCodes {
63
- grants[i] = model.RolePermission{RoleCode: roleCode, PermissionCode: code}
64
- }
65
- return t.Create(&grants).Error
66
- })
67
- }
68
-
69
- func (r *Repository) Delete(ctx context.Context, code string) error {
70
- return tx.From(ctx, r.db).WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
71
- }
72
-
73
- func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
74
- var items []model.Permission
75
- err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
76
- return items, err
77
- }
78
-
79
- // AllPermissionCodes returns every permission code that exists, unpaginated
80
- // — the catalog is a handful of rows, cheap to fetch whole for validating a
81
- // SetPermissions request.
82
- func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
83
- var codes []string
84
- err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
85
- return codes, err
86
- }
87
-
88
- // CountRolesWithPermission counts roles other than excludeCode that grant
89
- // permissionCode — used to check whether editing or deleting excludeCode
90
- // would leave no role holding permissionCode at all.
91
- func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
92
- var count int64
93
- err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.RolePermission{}).
94
- Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).
95
- Count(&count).Error
96
- return count, err
97
- }
@@ -1,217 +0,0 @@
1
- package role
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "regexp"
7
- "slices"
8
-
9
- "{{goModule}}/internal/app/role/model"
10
- "{{goModule}}/internal/shared/apperror"
11
- "{{goModule}}/internal/shared/dberr"
12
-
13
- "gorm.io/gorm"
14
- )
15
-
16
- // repository = what the service needs from the data layer (declared on the
17
- // consumer side, so it can be faked in tests)
18
- type repository interface {
19
- PermissionCodes(ctx context.Context, roleCode string) ([]string, error)
20
- FindAll(ctx context.Context, limit, offset int) ([]model.Role, error)
21
- FindByCode(ctx context.Context, code string) (*model.Role, error)
22
- Create(ctx context.Context, m *model.Role) error
23
- SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error
24
- Delete(ctx context.Context, code string) error
25
- FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error)
26
- AllPermissionCodes(ctx context.Context) ([]string, error)
27
- CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error)
28
- }
29
-
30
- // Service = business logic for role (rules live here, it knows nothing about HTTP)
31
- type Service struct {
32
- repo repository
33
- }
34
-
35
- func NewService(repo repository) *Service {
36
- return &Service{repo: repo}
37
- }
38
-
39
- // PermissionsOf resolves roleCode's permission set. This is the func value
40
- // injected into middleware.Authz from main.go — shared/ can't import a
41
- // domain package, so it calls this through a func, not a direct dependency.
42
- func (s *Service) PermissionsOf(ctx context.Context, roleCode string) (map[string]struct{}, error) {
43
- codes, err := s.repo.PermissionCodes(ctx, roleCode)
44
- if err != nil {
45
- return nil, err
46
- }
47
- set := make(map[string]struct{}, len(codes))
48
- for _, c := range codes {
49
- set[c] = struct{}{}
50
- }
51
- return set, nil
52
- }
53
-
54
- // CodeExists reports whether code names a real role — used by user.Service's
55
- // SetRole to validate a role assignment: AutoMigrate-only test schemas never
56
- // create the users_role_fkey FK, so that alone can't be relied on to reject
57
- // an unknown code.
58
- func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
59
- if _, err := s.repo.FindByCode(ctx, code); err != nil {
60
- if errors.Is(err, gorm.ErrRecordNotFound) {
61
- return false, nil
62
- }
63
- return false, err
64
- }
65
- return true, nil
66
- }
67
-
68
- // roleListItem pairs a role with its granted permission codes — every
69
- // role-shaped response (list, create, set-permissions) carries both.
70
- type roleListItem struct {
71
- model.Role
72
- Permissions []string
73
- }
74
-
75
- func (s *Service) List(ctx context.Context, limit, offset int) ([]roleListItem, error) {
76
- roles, err := s.repo.FindAll(ctx, limit, offset)
77
- if err != nil {
78
- return nil, apperror.NewInternal()
79
- }
80
- // ponytail: one query per role, not a join. A system has a handful of
81
- // roles, so this is a handful of round trips on an admin screen nobody
82
- // loads in a loop — the join costs more to read than it saves.
83
- //
84
- // Do not copy this shape into a domain whose list is user-sized. There the
85
- // upgrade is one query with a join (or an IN over the page's ids) and a
86
- // group-by in Go; here it would be ceremony.
87
- out := make([]roleListItem, len(roles))
88
- for i, r := range roles {
89
- perms, err := s.repo.PermissionCodes(ctx, r.Code)
90
- if err != nil {
91
- return nil, apperror.NewInternal()
92
- }
93
- out[i] = roleListItem{Role: r, Permissions: perms}
94
- }
95
- return out, nil
96
- }
97
-
98
- // roleCodePattern mirrors the seeded system codes' shape (staff, admin) —
99
- // lowercase, no spaces, safe to use as a URL path segment and a JWT claim.
100
- var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
101
-
102
- func (s *Service) Create(ctx context.Context, in CreateInput) (*roleListItem, error) {
103
- if !roleCodePattern.MatchString(in.Code) {
104
- return nil, errInvalidCode()
105
- }
106
- m := &model.Role{Code: in.Code, Name: in.Name}
107
- if err := s.repo.Create(ctx, m); err != nil {
108
- if dberr.IsDuplicate(err) {
109
- return nil, errConflict()
110
- }
111
- return nil, apperror.NewInternal()
112
- }
113
- return &roleListItem{Role: *m, Permissions: []string{}}, nil
114
- }
115
-
116
- // SetPermissions replaces code's entire permission grant set with
117
- // permissionCodes — a full-replace, not add/remove, matches a checkbox-style
118
- // admin UI ("here's the new full set") better than diffing.
119
- func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*roleListItem, error) {
120
- m, err := s.repo.FindByCode(ctx, code)
121
- if err != nil {
122
- return nil, wrapFindErr(err)
123
- }
124
- if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
125
- return nil, err
126
- }
127
- if !slices.Contains(permissionCodes, PermRoleManage) {
128
- isLast, err := s.isLastRoleManager(ctx, code)
129
- if err != nil {
130
- return nil, apperror.NewInternal()
131
- }
132
- if isLast {
133
- return nil, errLastRoleManager()
134
- }
135
- }
136
- if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
137
- // the migration's FK is a backstop for this same check — reachable
138
- // only under a race with a concurrent permission deletion.
139
- if dberr.IsForeignKey(err) {
140
- return nil, errUnknownPermission()
141
- }
142
- return nil, apperror.NewInternal()
143
- }
144
- return &roleListItem{Role: *m, Permissions: permissionCodes}, nil
145
- }
146
-
147
- // validatePermissionCodes checks every code against the actual permission
148
- // catalog — the DB's FK alone isn't enough (AutoMigrate, used in dev/tests,
149
- // never creates it, only the raw migration SQL does).
150
- func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
151
- all, err := s.repo.AllPermissionCodes(ctx)
152
- if err != nil {
153
- return apperror.NewInternal()
154
- }
155
- valid := make(map[string]struct{}, len(all))
156
- for _, c := range all {
157
- valid[c] = struct{}{}
158
- }
159
- for _, c := range codes {
160
- if _, ok := valid[c]; !ok {
161
- return errUnknownPermission()
162
- }
163
- }
164
- return nil
165
- }
166
-
167
- func (s *Service) Delete(ctx context.Context, code string) error {
168
- m, err := s.repo.FindByCode(ctx, code)
169
- if err != nil {
170
- return wrapFindErr(err)
171
- }
172
- if m.IsSystem {
173
- return errSystemRole()
174
- }
175
- isLast, err := s.isLastRoleManager(ctx, code)
176
- if err != nil {
177
- return apperror.NewInternal()
178
- }
179
- if isLast {
180
- return errLastRoleManager()
181
- }
182
- if err := s.repo.Delete(ctx, code); err != nil {
183
- if dberr.IsForeignKey(err) {
184
- return errHasReferences()
185
- }
186
- return apperror.NewInternal()
187
- }
188
- return nil
189
- }
190
-
191
- // isLastRoleManager reports whether code is the only role left granting
192
- // PermRoleManage — blocks a self-lockout no one could fix back through this
193
- // same API. Best-effort: reachable only under a race with a concurrent edit
194
- // to the same permission, not worth a serializable transaction for an
195
- // admin-only, low-traffic action.
196
- func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
197
- others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
198
- if err != nil {
199
- return false, err
200
- }
201
- return others == 0, nil
202
- }
203
-
204
- func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
205
- items, err := s.repo.FindAllPermissions(ctx, limit, offset)
206
- if err != nil {
207
- return nil, apperror.NewInternal()
208
- }
209
- return items, nil
210
- }
211
-
212
- func wrapFindErr(err error) error {
213
- if errors.Is(err, gorm.ErrRecordNotFound) {
214
- return errNotFound()
215
- }
216
- return apperror.NewInternal()
217
- }
@@ -1,36 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "time"
5
-
6
- "{{goModule}}/internal/app/{{modulePath}}/model"
7
-
8
- "github.com/google/uuid"
9
- )
10
-
11
- // TODO: add request fields, e.g. Name string `json:"name" binding:"required"`
12
- // When you do: update createBody in handler_test.go to match, or the
13
- // generated create test starts failing on its own empty `{}` body.
14
- type createInput struct {
15
- }
16
-
17
- // TODO: add request fields, e.g. Name string `json:"name" binding:"omitempty"`
18
- type updateInput struct {
19
- // Version the client last read. Required: without it an update is a blind
20
- // overwrite of whatever anyone else has since saved.
21
- Version int `json:"version" binding:"required"`
22
- }
23
-
24
- // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
25
- type response struct {
26
- ID uuid.UUID `json:"id"`
27
- CreatedAt time.Time `json:"created_at"`
28
- // echoed back so the client can send it with its next update
29
- Version int `json:"version"`
30
- }
31
-
32
- func toResponse(m *model.{{pascalName}}) response {
33
- return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
34
- }
35
-
36
- // go-scaffold:dto
@@ -1,33 +0,0 @@
1
- package {{pkg}}
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/apperror"
7
- )
8
-
9
- // error catalog specific to {{pkg}} — codes carry more meaning than the generic apperror ones
10
- // functions, not vars: the error middleware writes RequestID onto the returned pointer directly
11
- // (a shared instance would race across concurrent requests). Not every entry is called by every
12
- // module (a minimal module calls none of these until `generate method` wires one in), hence nolint.
13
- //
14
- //nolint:unused
15
- func errNotFound() *apperror.AppError {
16
- return apperror.New(http.StatusNotFound, "{{errorPrefix}}_NOT_FOUND", "{{pkg}} not found")
17
- }
18
-
19
- //nolint:unused
20
- func errConflict() *apperror.AppError {
21
- // TODO: rename/reword once a real unique field exists (e.g. email already exists)
22
- return apperror.New(http.StatusConflict, "{{errorPrefix}}_CONFLICT", "{{pkg}} already exists")
23
- }
24
-
25
- //nolint:unused
26
- func errStale() *apperror.AppError {
27
- return apperror.New(http.StatusConflict, "{{errorPrefix}}_STALE", "{{pkg}} was modified by someone else — reload and try again")
28
- }
29
-
30
- //nolint:unused
31
- func errHasReferences() *apperror.AppError {
32
- return apperror.New(http.StatusConflict, "{{errorPrefix}}_HAS_REFERENCES", "{{pkg}} still has related records")
33
- }