@nakedev/go-scaffold 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,37 @@
1
+ RoleCreateInput:
2
+ type: object
3
+ required: [code, name]
4
+ properties:
5
+ code: { type: string, maxLength: 20, description: "lowercase, digits, underscores only" }
6
+ name: { type: string }
7
+
8
+ SetPermissionsInput:
9
+ type: object
10
+ properties:
11
+ permission_codes:
12
+ type: array
13
+ items: { type: string }
14
+ description: "full replacement of the role's permission set — an empty list revokes every permission"
15
+
16
+ RoleResponse:
17
+ type: object
18
+ properties:
19
+ code: { type: string }
20
+ name: { type: string }
21
+ is_system: { type: boolean, description: "staff/admin — seeded, cannot be deleted" }
22
+ permissions:
23
+ type: array
24
+ items: { type: string }
25
+ created_at: { type: string, format: date-time }
26
+
27
+ PermissionResponse:
28
+ type: object
29
+ properties:
30
+ code: { type: string }
31
+ description: { type: string }
32
+
33
+ SetRoleInput:
34
+ type: object
35
+ required: [role]
36
+ properties:
37
+ role: { type: string, description: "must be an existing role code" }
@@ -0,0 +1,23 @@
1
+ parameters:
2
+ - $ref: '../common/parameters.yaml#/IdParam'
3
+ patch:
4
+ summary: Change a user's role
5
+ operationId: setUserRole
6
+ tags: [rbac]
7
+ security: [{ bearerAuth: [] }]
8
+ requestBody:
9
+ required: true
10
+ content:
11
+ application/json:
12
+ schema: { $ref: './schemas.yaml#/SetRoleInput' }
13
+ responses:
14
+ "200":
15
+ description: updated
16
+ content:
17
+ application/json:
18
+ schema: { $ref: '../auth/schemas.yaml#/MeResponse' }
19
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
20
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
21
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
22
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
23
+ "422": { $ref: '../common/responses.yaml#/UnprocessableEntityError' }
@@ -0,0 +1,16 @@
1
+ parameters:
2
+ - $ref: '../common/parameters.yaml#/IdParam'
3
+ get:
4
+ summary: Get a user (admin)
5
+ operationId: adminGetUser
6
+ tags: [rbac]
7
+ security: [{ bearerAuth: [] }]
8
+ responses:
9
+ "200":
10
+ description: ok
11
+ content:
12
+ application/json:
13
+ schema: { $ref: '../auth/schemas.yaml#/MeResponse' }
14
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
15
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
16
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
@@ -0,0 +1,23 @@
1
+ get:
2
+ summary: List users (admin)
3
+ operationId: adminListUsers
4
+ tags: [rbac]
5
+ security: [{ bearerAuth: [] }]
6
+ parameters:
7
+ - $ref: '../common/parameters.yaml#/Limit'
8
+ - $ref: '../common/parameters.yaml#/Offset'
9
+ responses:
10
+ "200":
11
+ description: paginated list
12
+ content:
13
+ application/json:
14
+ schema:
15
+ allOf:
16
+ - $ref: '../common/schemas.yaml#/PageEnvelope'
17
+ - type: object
18
+ properties:
19
+ data:
20
+ type: array
21
+ items: { $ref: '../auth/schemas.yaml#/MeResponse' }
22
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
23
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
@@ -0,0 +1,40 @@
1
+ package role
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/role/model"
7
+ )
8
+
9
+ // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
10
+ type response struct {
11
+ Code string `json:"code"`
12
+ Name string `json:"name"`
13
+ IsSystem bool `json:"is_system"`
14
+ Permissions []string `json:"permissions"`
15
+ CreatedAt time.Time `json:"created_at"`
16
+ }
17
+
18
+ func toResponse(m *model.Role, perms []string) response {
19
+ return response{Code: m.Code, Name: m.Name, IsSystem: m.IsSystem, Permissions: perms, CreatedAt: m.CreatedAt}
20
+ }
21
+
22
+ type CreateInput struct {
23
+ Code string `json:"code" binding:"required,max=20"`
24
+ Name string `json:"name" binding:"required"`
25
+ }
26
+
27
+ // SetPermissionsInput.PermissionCodes is deliberately not `binding:"required"`
28
+ // — an empty list is valid (it revokes every permission the role had).
29
+ type SetPermissionsInput struct {
30
+ PermissionCodes []string `json:"permission_codes"`
31
+ }
32
+
33
+ type permissionResponse struct {
34
+ Code string `json:"code"`
35
+ Description string `json:"description"`
36
+ }
37
+
38
+ func toPermissionResponse(m *model.Permission) permissionResponse {
39
+ return permissionResponse{Code: m.Code, Description: m.Description}
40
+ }
@@ -0,0 +1,39 @@
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
+ }
@@ -0,0 +1,101 @@
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
+ c.JSON(http.StatusOK, toResponse(&item.Role, item.Permissions))
79
+ }
80
+
81
+ func (h *Handler) delete(c *gin.Context) {
82
+ if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
83
+ c.Error(err)
84
+ return
85
+ }
86
+ c.Status(http.StatusNoContent)
87
+ }
88
+
89
+ func (h *Handler) listPermissions(c *gin.Context) {
90
+ p := pagination.Parse(c)
91
+ items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
92
+ if err != nil {
93
+ c.Error(err)
94
+ return
95
+ }
96
+ out := make([]permissionResponse, len(items))
97
+ for i := range items {
98
+ out[i] = toPermissionResponse(&items[i])
99
+ }
100
+ c.JSON(http.StatusOK, p.Response(out))
101
+ }
@@ -0,0 +1,9 @@
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
+ }
@@ -0,0 +1,18 @@
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
+ }
@@ -0,0 +1,8 @@
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
+ }
@@ -0,0 +1,96 @@
1
+ package role
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/role/model"
7
+
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ // Repository = data access for role (the only place that touches the DB for this domain)
12
+ // every method takes ctx, so a cancelled request cancels the query too
13
+ type Repository struct {
14
+ db *gorm.DB
15
+ }
16
+
17
+ func NewRepository(db *gorm.DB) *Repository {
18
+ return &Repository{db: db}
19
+ }
20
+
21
+ // PermissionCodes returns the permission codes granted to roleCode.
22
+ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
23
+ var codes []string
24
+ err := r.db.WithContext(ctx).
25
+ Model(&model.RolePermission{}).
26
+ Where("role_code = ?", roleCode).
27
+ Pluck("permission_code", &codes).Error
28
+ return codes, err
29
+ }
30
+
31
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.Role, error) {
32
+ var items []model.Role
33
+ err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
34
+ return items, err
35
+ }
36
+
37
+ func (r *Repository) FindByCode(ctx context.Context, code string) (*model.Role, error) {
38
+ var m model.Role
39
+ if err := r.db.WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
40
+ return nil, err
41
+ }
42
+ return &m, nil
43
+ }
44
+
45
+ func (r *Repository) Create(ctx context.Context, m *model.Role) error {
46
+ return r.db.WithContext(ctx).Create(m).Error
47
+ }
48
+
49
+ // SetPermissions replaces roleCode's entire permission grant set in one
50
+ // transaction — simpler for a checkbox-style admin UI than diffing
51
+ // add/remove, and cheap at this scale (a handful of rows per role).
52
+ func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
53
+ return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
54
+ if err := tx.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
55
+ return err
56
+ }
57
+ if len(permissionCodes) == 0 {
58
+ return nil
59
+ }
60
+ grants := make([]model.RolePermission, len(permissionCodes))
61
+ for i, code := range permissionCodes {
62
+ grants[i] = model.RolePermission{RoleCode: roleCode, PermissionCode: code}
63
+ }
64
+ return tx.Create(&grants).Error
65
+ })
66
+ }
67
+
68
+ func (r *Repository) Delete(ctx context.Context, code string) error {
69
+ return r.db.WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
70
+ }
71
+
72
+ func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
73
+ var items []model.Permission
74
+ err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
75
+ return items, err
76
+ }
77
+
78
+ // AllPermissionCodes returns every permission code that exists, unpaginated
79
+ // — the catalog is a handful of rows, cheap to fetch whole for validating a
80
+ // SetPermissions request.
81
+ func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
82
+ var codes []string
83
+ err := r.db.WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
84
+ return codes, err
85
+ }
86
+
87
+ // CountRolesWithPermission counts roles other than excludeCode that grant
88
+ // permissionCode — used to check whether editing or deleting excludeCode
89
+ // would leave no role holding permissionCode at all.
90
+ func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
91
+ var count int64
92
+ err := r.db.WithContext(ctx).Model(&model.RolePermission{}).
93
+ Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).
94
+ Count(&count).Error
95
+ return count, err
96
+ }
@@ -0,0 +1,210 @@
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
+ out := make([]roleListItem, len(roles))
81
+ for i, r := range roles {
82
+ perms, err := s.repo.PermissionCodes(ctx, r.Code)
83
+ if err != nil {
84
+ return nil, apperror.NewInternal()
85
+ }
86
+ out[i] = roleListItem{Role: r, Permissions: perms}
87
+ }
88
+ return out, nil
89
+ }
90
+
91
+ // roleCodePattern mirrors the seeded system codes' shape (staff, admin) —
92
+ // lowercase, no spaces, safe to use as a URL path segment and a JWT claim.
93
+ var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
94
+
95
+ func (s *Service) Create(ctx context.Context, in CreateInput) (*roleListItem, error) {
96
+ if !roleCodePattern.MatchString(in.Code) {
97
+ return nil, errInvalidCode()
98
+ }
99
+ m := &model.Role{Code: in.Code, Name: in.Name}
100
+ if err := s.repo.Create(ctx, m); err != nil {
101
+ if dberr.IsDuplicate(err) {
102
+ return nil, errConflict()
103
+ }
104
+ return nil, apperror.NewInternal()
105
+ }
106
+ return &roleListItem{Role: *m, Permissions: []string{}}, nil
107
+ }
108
+
109
+ // SetPermissions replaces code's entire permission grant set with
110
+ // permissionCodes — a full-replace, not add/remove, matches a checkbox-style
111
+ // admin UI ("here's the new full set") better than diffing.
112
+ func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*roleListItem, error) {
113
+ m, err := s.repo.FindByCode(ctx, code)
114
+ if err != nil {
115
+ return nil, wrapFindErr(err)
116
+ }
117
+ if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
118
+ return nil, err
119
+ }
120
+ if !slices.Contains(permissionCodes, PermRoleManage) {
121
+ isLast, err := s.isLastRoleManager(ctx, code)
122
+ if err != nil {
123
+ return nil, apperror.NewInternal()
124
+ }
125
+ if isLast {
126
+ return nil, errLastRoleManager()
127
+ }
128
+ }
129
+ if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
130
+ // the migration's FK is a backstop for this same check — reachable
131
+ // only under a race with a concurrent permission deletion.
132
+ if dberr.IsForeignKey(err) {
133
+ return nil, errUnknownPermission()
134
+ }
135
+ return nil, apperror.NewInternal()
136
+ }
137
+ return &roleListItem{Role: *m, Permissions: permissionCodes}, nil
138
+ }
139
+
140
+ // validatePermissionCodes checks every code against the actual permission
141
+ // catalog — the DB's FK alone isn't enough (AutoMigrate, used in dev/tests,
142
+ // never creates it, only the raw migration SQL does).
143
+ func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
144
+ all, err := s.repo.AllPermissionCodes(ctx)
145
+ if err != nil {
146
+ return apperror.NewInternal()
147
+ }
148
+ valid := make(map[string]struct{}, len(all))
149
+ for _, c := range all {
150
+ valid[c] = struct{}{}
151
+ }
152
+ for _, c := range codes {
153
+ if _, ok := valid[c]; !ok {
154
+ return errUnknownPermission()
155
+ }
156
+ }
157
+ return nil
158
+ }
159
+
160
+ func (s *Service) Delete(ctx context.Context, code string) error {
161
+ m, err := s.repo.FindByCode(ctx, code)
162
+ if err != nil {
163
+ return wrapFindErr(err)
164
+ }
165
+ if m.IsSystem {
166
+ return errSystemRole()
167
+ }
168
+ isLast, err := s.isLastRoleManager(ctx, code)
169
+ if err != nil {
170
+ return apperror.NewInternal()
171
+ }
172
+ if isLast {
173
+ return errLastRoleManager()
174
+ }
175
+ if err := s.repo.Delete(ctx, code); err != nil {
176
+ if dberr.IsForeignKey(err) {
177
+ return errHasReferences()
178
+ }
179
+ return apperror.NewInternal()
180
+ }
181
+ return nil
182
+ }
183
+
184
+ // isLastRoleManager reports whether code is the only role left granting
185
+ // PermRoleManage — blocks a self-lockout no one could fix back through this
186
+ // same API. Best-effort: reachable only under a race with a concurrent edit
187
+ // to the same permission, not worth a serializable transaction for an
188
+ // admin-only, low-traffic action.
189
+ func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
190
+ others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
191
+ if err != nil {
192
+ return false, err
193
+ }
194
+ return others == 0, nil
195
+ }
196
+
197
+ func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
198
+ items, err := s.repo.FindAllPermissions(ctx, limit, offset)
199
+ if err != nil {
200
+ return nil, apperror.NewInternal()
201
+ }
202
+ return items, nil
203
+ }
204
+
205
+ func wrapFindErr(err error) error {
206
+ if errors.Is(err, gorm.ErrRecordNotFound) {
207
+ return errNotFound()
208
+ }
209
+ return apperror.NewInternal()
210
+ }