@starci/hfs 4.2.13 → 4.2.15

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 (203) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +7 -3
  3. package/emit/db-types.mjs +2 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +200 -8
  6. package/runtime/knowledge/hfs/fe-stack.yaml +221 -0
  7. package/runtime/knowledge/hfs/rules.yaml +50 -47
  8. package/runtime/knowledge/hfs/slots.yaml +307 -179
  9. package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
  10. package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
  11. package/runtime/knowledge/patterns/be/projections.yaml +10 -0
  12. package/runtime/knowledge/patterns/be/queues.yaml +15 -0
  13. package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
  14. package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
  15. package/runtime/knowledge/patterns/be/saga.yaml +13 -0
  16. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  17. package/runtime/knowledge/sonar-gate.yaml +10 -2
  18. package/runtime/modules/kernel/failure-codes.yaml +40 -60
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +43 -0
  20. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  21. package/runtime/scripts/hfs/architecture/config.mjs +2 -1
  22. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  23. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  24. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  25. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  26. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  27. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  28. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  29. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  30. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  31. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  32. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  33. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  34. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  35. package/runtime/scripts/hfs/check.mjs +10 -6
  36. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  37. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  38. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  39. package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
  40. package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
  41. package/runtime/scripts/hfs/rules/database.mjs +4 -3
  42. package/runtime/scripts/hfs/rules/edition.mjs +49 -37
  43. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  44. package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
  45. package/scaffold/add-app.mjs +24 -18
  46. package/scaffold/add-cli-lite.mjs +33 -3
  47. package/scaffold/add-inbox-lite.mjs +24 -8
  48. package/scaffold/add-table.mjs +222 -74
  49. package/scaffold/add.mjs +85 -35
  50. package/scaffold/app.mjs +140 -36
  51. package/scaffold/edition-gate.mjs +21 -2
  52. package/scaffold/lite-exports.mjs +30 -1
  53. package/scaffold/service.mjs +5 -8
  54. package/src/main.mjs +2 -2
  55. package/sync/index.mjs +19 -15
  56. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
  57. package/templates/app/codegen-graphql.mjs.partial +34 -0
  58. package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
  59. package/templates/app/package-scripts/core.json +1 -0
  60. package/templates/app/package-scripts-lite/package.json +4 -0
  61. package/templates/app/skeleton/README.md +1 -1
  62. package/templates/app/skeleton/scripts/codegen.mjs +4 -1
  63. package/templates/app/skeleton-lite/README.md +7 -4
  64. package/templates/app/skeleton-lite/supabase/config.toml +1 -1
  65. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
  66. package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
  67. package/templates/be/inbox/service.spec.ts.tpl +46 -0
  68. package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
  69. package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
  70. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
  71. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
  72. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
  73. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
  74. package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
  75. package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
  76. package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
  77. package/templates/be/platform/graphql/graphql.module.ts +49 -0
  78. package/templates/be/platform/graphql/graphql.options.ts +2 -0
  79. package/templates/be/platform/graphql/index.ts +1 -0
  80. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
  81. package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
  82. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
  83. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
  84. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
  85. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
  86. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
  87. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
  88. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
  89. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
  90. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
  91. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
  92. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
  93. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  94. package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
  95. package/templates/be/table/api/create.command.ts.tpl +10 -0
  96. package/templates/be/table/api/create.contracts.ts.tpl +8 -0
  97. package/templates/be/table/api/create.handler.ts.tpl +22 -0
  98. package/templates/be/table/api/create.mapper.ts.tpl +5 -0
  99. package/templates/be/table/api/create.resolver.ts.tpl +21 -0
  100. package/templates/be/table/api/create.type.ts.tpl +9 -0
  101. package/templates/be/table/api/feature.index.ts.tpl +1 -0
  102. package/templates/be/table/api/feature.module.ts.tpl +7 -0
  103. package/templates/be/table/api/get.contracts.ts.tpl +11 -0
  104. package/templates/be/table/api/get.handler.ts.tpl +22 -0
  105. package/templates/be/table/api/get.input.ts.tpl +12 -0
  106. package/templates/be/table/api/get.mapper.ts.tpl +9 -0
  107. package/templates/be/table/api/get.query.ts.tpl +10 -0
  108. package/templates/be/table/api/get.resolver.ts.tpl +27 -0
  109. package/templates/be/table/api/get.type.ts.tpl +9 -0
  110. package/templates/be/table/api/graphql.module.ts.tpl +8 -0
  111. package/templates/be/table/error.api.ts.tpl +19 -0
  112. package/templates/be/table/migration.sql.tpl +13 -21
  113. package/templates/be/table/service.api.ts.tpl +30 -0
  114. package/templates/be/table/sql.api.ts.tpl +7 -0
  115. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
  116. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
  117. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
  118. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
  119. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
  120. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
  121. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
  122. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
  123. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
  124. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
  125. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
  126. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
  127. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
  128. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
  129. package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
  130. package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
  131. package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
  132. package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
  133. package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
  134. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
  135. package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
  136. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +32 -0
  137. package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
  138. package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
  139. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
  140. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
  141. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
  142. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
  143. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
  144. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
  145. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
  146. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
  147. package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
  148. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
  149. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
  150. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
  151. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
  152. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
  153. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
  154. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
  155. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
  156. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
  157. package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
  158. package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
  159. package/templates/fe/table/graphql/query.ts.tpl +25 -0
  160. package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
  161. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
  162. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
  163. package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
  164. package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
  165. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
  166. package/templates/fe/transport/graphql/clients/index.ts +2 -0
  167. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
  168. package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
  169. package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
  170. package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
  171. package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
  172. package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
  173. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
  174. package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
  175. package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
  176. package/templates/fe/transport/graphql/clients/response.ts +43 -0
  177. package/upgrade/check.mjs +1 -1
  178. package/upgrade/index.mjs +3 -2
  179. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
  180. package/templates/app/quality-config-lite/sonar-project.properties +0 -5
  181. package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
  182. package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
  183. package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
  184. package/templates/be/patterns/api/http.module.ts.tpl +0 -7
  185. package/templates/be/patterns/api/http.request.ts.tpl +0 -10
  186. package/templates/be/patterns/api/http.response.ts.tpl +0 -5
  187. package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
  188. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
  189. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
  192. package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
  193. package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
  194. package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
  195. package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
  196. package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
  197. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
  198. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
  199. package/templates/fe/table/read.ts.tpl +0 -16
  200. package/templates/fe/table/write.ts.tpl +0 -16
  201. /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
  202. /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
  203. /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
@@ -0,0 +1,114 @@
1
+ import type { GraphQLRequestContext, GraphQLRequestContextWillSendResponse, GraphQLResponse } from "@apollo/server"
2
+ import { mock } from "@starci/jest-preset"
3
+ import type { Request } from "express"
4
+ import type { GraphQLFormattedError } from "graphql"
5
+ import type { ErrorsService } from "@modules/platform/errors"
6
+ import type { RequestLocale } from "@modules/platform/i18n"
7
+ import type { GraphqlContext } from "./graphql.contracts"
8
+ import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
9
+
10
+ const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
11
+ mock<GraphQLResponse>({ body: { kind: "single", singleResult: { errors } } })
12
+
13
+ const requestContext = (
14
+ response: GraphQLResponse,
15
+ headers: Request["headers"],
16
+ ): GraphQLRequestContextWillSendResponse<GraphqlContext> =>
17
+ mock<GraphQLRequestContextWillSendResponse<GraphqlContext>>({
18
+ contextValue: { req: mock<Request>({ headers }), res: mock<GraphqlContext["res"]>() },
19
+ response,
20
+ })
21
+
22
+ const sendResponse = async (
23
+ errors: ErrorsService,
24
+ requestLocale: RequestLocale,
25
+ context: GraphQLRequestContextWillSendResponse<GraphqlContext>,
26
+ ): Promise<void> => {
27
+ const listener = await localizeErrorsPlugin(errors, requestLocale).requestDidStart?.(
28
+ mock<GraphQLRequestContext<GraphqlContext>>(),
29
+ )
30
+ await listener?.willSendResponse?.(context)
31
+ }
32
+
33
+ describe("localizeError", () => {
34
+ it("returns an error without a string code unchanged", () => {
35
+ const errors = mock<ErrorsService>()
36
+ const error: GraphQLFormattedError = { message: "syntax error", extensions: { kind: "invalid" } }
37
+
38
+ expect(localizeError(errors, "en", error)).toBe(error)
39
+ expect(errors.text).not.toHaveBeenCalled()
40
+ })
41
+
42
+ it("replaces a coded error's message with the catalog text and keeps only string and number params", () => {
43
+ const errors = mock<ErrorsService>()
44
+ errors.text.mockReturnValue("The item was not found")
45
+ const error: GraphQLFormattedError = {
46
+ message: "ITEM_NOT_FOUND",
47
+ extensions: { code: "ITEM_NOT_FOUND", params: { id: "i-1", retries: 2, transient: false } },
48
+ }
49
+
50
+ expect(localizeError(errors, "en", error)).toEqual({ ...error, message: "The item was not found" })
51
+ expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", { id: "i-1", retries: 2 }, "en")
52
+ })
53
+
54
+ it.each([undefined, null, ["i-1"], "i-1"])("interpolates no params when extensions.params is %p", (params) => {
55
+ const errors = mock<ErrorsService>()
56
+ errors.text.mockReturnValue("The item was not found")
57
+ const error: GraphQLFormattedError = {
58
+ message: "ITEM_NOT_FOUND",
59
+ extensions: { code: "ITEM_NOT_FOUND", params },
60
+ }
61
+
62
+ expect(localizeError(errors, "en", error).message).toBe("The item was not found")
63
+ expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "en")
64
+ })
65
+ })
66
+
67
+ describe("localizeErrorsPlugin", () => {
68
+ it("localizes a coded error in the language the x-locale header declares", async () => {
69
+ const errors = mock<ErrorsService>()
70
+ const requestLocale = mock<RequestLocale>()
71
+ requestLocale.of.mockReturnValue("vi")
72
+ errors.text.mockReturnValue("localized: not found")
73
+ const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
74
+ const response = singleResponse([coded])
75
+
76
+ const context = requestContext(response, { "x-locale": "vi", "accept-language": "en" })
77
+
78
+ await sendResponse(errors, requestLocale, context)
79
+
80
+ expect(requestLocale.of).toHaveBeenCalledWith("vi")
81
+ expect(response.body.kind === "single" && response.body.singleResult.errors).toEqual([
82
+ { ...coded, message: "localized: not found" },
83
+ ])
84
+ })
85
+
86
+ it.each([{}, { "x-locale": "" }])(
87
+ "falls back to Accept-Language when the x-locale header is absent or empty (%p)",
88
+ async (declared) => {
89
+ const errors = mock<ErrorsService>()
90
+ const requestLocale = mock<RequestLocale>()
91
+ requestLocale.of.mockReturnValue("en")
92
+ errors.text.mockReturnValue("The item was not found")
93
+ const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
94
+
95
+ await sendResponse(
96
+ errors,
97
+ requestLocale,
98
+ requestContext(singleResponse([coded]), { ...declared, "accept-language": "en" }),
99
+ )
100
+
101
+ expect(requestLocale.of).toHaveBeenCalledWith("en")
102
+ },
103
+ )
104
+
105
+ it("leaves a response without errors alone", async () => {
106
+ const errors = mock<ErrorsService>()
107
+ const requestLocale = mock<RequestLocale>()
108
+
109
+ await sendResponse(errors, requestLocale, requestContext(singleResponse(), { "accept-language": "en" }))
110
+
111
+ expect(requestLocale.of).not.toHaveBeenCalled()
112
+ expect(errors.text).not.toHaveBeenCalled()
113
+ })
114
+ })
@@ -0,0 +1,51 @@
1
+ import type { ApolloServerPlugin } from "@apollo/server"
2
+ import type { GraphQLFormattedError } from "graphql"
3
+ import type { ErrorParams, ErrorsService } from "@modules/platform/errors"
4
+ import type { Locale, RequestLocale } from "@modules/platform/i18n"
5
+ import type { GraphqlContext } from "./graphql.contracts"
6
+
7
+ /** The interpolation params of an error: the string and number entries of its `extensions.params` record. */
8
+ const paramsOf = (value: unknown): ErrorParams => {
9
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {}
10
+ const params: Record<string, string | number> = {}
11
+ for (const [key, entry] of Object.entries(value)) {
12
+ if (typeof entry === "string" || typeof entry === "number") params[key] = entry
13
+ }
14
+ return params
15
+ }
16
+
17
+ /** The language a request asks for: the `x-locale` header the front end's locale link writes, else Accept-Language. */
18
+ const requestedLocale = (context: GraphqlContext): string | undefined => {
19
+ const declared = context.req.headers["x-locale"]
20
+ return typeof declared === "string" && declared !== "" ? declared : context.req.headers["accept-language"]
21
+ }
22
+
23
+ /** Replaces the message of an error carrying a code with the catalog text of that code in `locale`; other errors pass unchanged. */
24
+ export const localizeError = (
25
+ errors: ErrorsService,
26
+ locale: Locale,
27
+ error: GraphQLFormattedError,
28
+ ): GraphQLFormattedError => {
29
+ const code = error.extensions?.code
30
+ if (typeof code !== "string") return error
31
+ return { ...error, message: errors.text(code, paramsOf(error.extensions?.params), locale) }
32
+ }
33
+
34
+ /** An Apollo plugin that localizes every coded error of a response in the language of the request. */
35
+ export const localizeErrorsPlugin = (
36
+ errors: ErrorsService,
37
+ requestLocale: RequestLocale,
38
+ ): ApolloServerPlugin<GraphqlContext> => ({
39
+ requestDidStart: () =>
40
+ Promise.resolve({
41
+ willSendResponse: ({ contextValue, response }) => {
42
+ if (response.body.kind === "single" && response.body.singleResult.errors) {
43
+ const locale = requestLocale.of(requestedLocale(contextValue))
44
+ response.body.singleResult.errors = response.body.singleResult.errors.map((error) =>
45
+ localizeError(errors, locale, error),
46
+ )
47
+ }
48
+ return Promise.resolve()
49
+ },
50
+ }),
51
+ })
@@ -12,6 +12,17 @@ const build = () => {
12
12
  }
13
13
 
14
14
  describe("ErrorsFilter", () => {
15
+ it("hands a failure in a GraphQL context back untouched for the GraphQL error formatter", () => {
16
+ const { filter, errors } = build()
17
+ const failure = new Error("resolver failed")
18
+ const host = mock<ArgumentsHost>()
19
+ host.getType.mockReturnValue("graphql")
20
+
21
+ expect(filter.catch(failure, host)).toBe(failure)
22
+ expect(errors.describe).not.toHaveBeenCalled()
23
+ expect(host.switchToHttp).not.toHaveBeenCalled()
24
+ })
25
+
15
26
  it("writes a localized description and its status to an HTTP response", () => {
16
27
  const { filter, errors, requestLocale } = build()
17
28
  const failure = new Error("note missing")
@@ -7,15 +7,19 @@ import { InjectErrorsService } from "./errors.decorators"
7
7
  import type { ErrorsService } from "./errors.service"
8
8
 
9
9
  @Catch()
10
- /** The one exception filter of an app: answers `{ code, kind, message, params }` with the status of the kind. */
10
+ /**
11
+ * The one REST exception filter of an app: answers `{ code, kind, message, params }` with the status of the kind. In a GraphQL
12
+ * context it hands the failure back untouched, so the GraphQL error formatter (platform/graphql) answers it.
13
+ */
11
14
  export class ErrorsFilter implements ExceptionFilter {
12
15
  constructor(
13
16
  @InjectErrorsService() private readonly errors: ErrorsService,
14
17
  @InjectRequestLocale() private readonly requestLocale: RequestLocale,
15
18
  ) {}
16
19
 
17
- /** Writes the description of `exception` on the HTTP response. */
18
- catch(exception: unknown, host: ArgumentsHost): void {
20
+ /** Writes the description of `exception` on the HTTP response, or returns it unchanged for GraphQL. */
21
+ catch(exception: unknown, host: ArgumentsHost): unknown {
22
+ if (host.getType<string>() === "graphql") return exception
19
23
  const http = host.switchToHttp()
20
24
  const request = http.getRequest<Request>()
21
25
  const description = this.errors.describe(exception)
@@ -28,5 +32,6 @@ export class ErrorsFilter implements ExceptionFilter {
28
32
  message: this.errors.text(description.code, description.params, locale),
29
33
  params: description.params,
30
34
  })
35
+ return undefined
31
36
  }
32
37
  }
@@ -4,13 +4,25 @@ import { mock } from "@starci/jest-preset"
4
4
  import { requestOf } from "./execution-request.mapper"
5
5
 
6
6
  describe("requestOf", () => {
7
- it("reads the HTTP request behind an execution context", () => {
7
+ it("reads the HTTP request behind an HTTP execution context", () => {
8
8
  const request = mock<Request>()
9
9
  const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
10
10
  http.getRequest.mockReturnValue(request)
11
11
  const context = mock<ExecutionContext>()
12
+ context.getType.mockReturnValue("http")
12
13
  context.switchToHttp.mockReturnValue(http)
13
14
 
14
15
  expect(requestOf(context)).toBe(request)
16
+ expect(context.getArgs).not.toHaveBeenCalled()
17
+ })
18
+
19
+ it("reads the request of the GraphQL context behind a resolver's execution context", () => {
20
+ const request = mock<Request>()
21
+ const context = mock<ExecutionContext>()
22
+ context.getType.mockReturnValue("graphql")
23
+ context.getArgs.mockReturnValue([undefined, {}, { req: request }, undefined])
24
+
25
+ expect(requestOf(context)).toBe(request)
26
+ expect(context.switchToHttp).not.toHaveBeenCalled()
15
27
  })
16
28
  })
@@ -1,5 +1,14 @@
1
1
  import type { ExecutionContext } from "@nestjs/common"
2
+ import { GqlExecutionContext } from "@nestjs/graphql"
2
3
  import type { Request } from "express"
3
4
 
4
- /** The HTTP request behind an execution context. */
5
- export const requestOf = (context: ExecutionContext): Request => context.switchToHttp().getRequest<Request>()
5
+ /** The GraphQL context the Apollo driver builds: the HTTP request the operation arrived on. */
6
+ interface GraphqlRequestContext {
7
+ readonly req: Request
8
+ }
9
+
10
+ /** The HTTP request behind an execution context, whether the door is a REST controller or a GraphQL resolver. */
11
+ export const requestOf = (context: ExecutionContext): Request =>
12
+ context.getType<string>() === "graphql"
13
+ ? GqlExecutionContext.create(context).getContext<GraphqlRequestContext>().req
14
+ : context.switchToHttp().getRequest<Request>()
@@ -0,0 +1,21 @@
1
+ import type { Principal } from "@modules/platform/cqrs"
2
+ import { admit } from "./admission.policy"
3
+ import { IdentityErrorCode } from "./errors/identity.error"
4
+ import { PublicReason } from "./identity.contracts"
5
+
6
+ const principal: Principal = { id: "user-1", role: "authenticated" }
7
+
8
+ describe("admit", () => {
9
+ it("admits a door that states why it is public, with or without a caller", () => {
10
+ expect(admit({ reason: PublicReason.Health }, undefined)).toSucceedWith(PublicReason.Health)
11
+ expect(admit({ reason: PublicReason.SignedWebhook }, principal)).toSucceedWith(PublicReason.SignedWebhook)
12
+ })
13
+
14
+ it("admits the verified principal of a door that is not public", () => {
15
+ expect(admit(undefined, principal)).toSucceedWith(principal)
16
+ })
17
+
18
+ it("refuses a door that is not public when no principal was verified", () => {
19
+ expect(admit(undefined, undefined)).toBeRefused(IdentityErrorCode.Unauthenticated)
20
+ })
21
+ })
@@ -0,0 +1,94 @@
1
+ import type { ExecutionContext } from "@nestjs/common"
2
+ import { Reflector } from "@nestjs/core"
3
+ import { Test } from "@nestjs/testing"
4
+ import type { Request } from "express"
5
+ import { mock } from "@starci/jest-preset"
6
+ import type { Principal } from "@modules/platform/cqrs"
7
+ import { ok, refused } from "@modules/platform/primitives"
8
+ import { SupabaseErrorCode } from "@modules/integrations/supabase"
9
+ import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
10
+ import { AuthGuard } from "./auth.guard"
11
+ import { IdentityErrorCode } from "./errors/identity.error"
12
+ import type { PublicMetadata } from "./identity.contracts"
13
+ import { PublicReason } from "./identity.contracts"
14
+ import { ACCESS_TOKEN_VERIFIER } from "./identity.decorators"
15
+
16
+ const principal: Principal = { id: "user-1", role: "authenticated" }
17
+
18
+ const build = async (metadata: PublicMetadata | undefined, verify: VerifySupabaseAccessToken) => {
19
+ const reflector = mock<Reflector>()
20
+ reflector.getAllAndOverride.mockReturnValue(metadata)
21
+ const moduleRef = await Test.createTestingModule({
22
+ providers: [
23
+ AuthGuard,
24
+ { provide: Reflector, useValue: reflector },
25
+ { provide: ACCESS_TOKEN_VERIFIER, useValue: verify },
26
+ ],
27
+ }).compile()
28
+ return moduleRef.get(AuthGuard)
29
+ }
30
+
31
+ const contextOf = (authorization: string | undefined) => {
32
+ const request = mock<Request>()
33
+ request.headers = authorization === undefined ? {} : { authorization }
34
+ request.principal = undefined
35
+ const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
36
+ http.getRequest.mockReturnValue(request)
37
+ const context = mock<ExecutionContext>()
38
+ context.getHandler.mockReturnValue(() => undefined)
39
+ context.getClass.mockReturnValue(class Door {})
40
+ context.switchToHttp.mockReturnValue(http)
41
+ return { context, request }
42
+ }
43
+
44
+ const unauthenticated = expect.objectContaining({ code: IdentityErrorCode.Unauthenticated })
45
+
46
+ describe("AuthGuard", () => {
47
+ it("lets a public door through without reading a token", async () => {
48
+ const verify = jest.fn<ReturnType<VerifySupabaseAccessToken>, Parameters<VerifySupabaseAccessToken>>()
49
+ const guard = await build({ reason: PublicReason.Health }, verify)
50
+ const { context } = contextOf("Bearer token")
51
+
52
+ await expect(guard.canActivate(context)).resolves.toBe(true)
53
+
54
+ expect(verify).not.toHaveBeenCalled()
55
+ })
56
+
57
+ it("admits a door that is not public for a verified bearer token and records the principal on the request", async () => {
58
+ const verify = jest.fn<ReturnType<VerifySupabaseAccessToken>, Parameters<VerifySupabaseAccessToken>>()
59
+ verify.mockResolvedValue(ok(principal))
60
+ const guard = await build(undefined, verify)
61
+ const { context, request } = contextOf("Bearer signed-token")
62
+
63
+ await expect(guard.canActivate(context)).resolves.toBe(true)
64
+
65
+ expect(verify).toHaveBeenCalledWith("signed-token")
66
+ expect(request.principal).toEqual(principal)
67
+ })
68
+
69
+ it("refuses a door that is not public when the token fails verification", async () => {
70
+ const verify = jest.fn<ReturnType<VerifySupabaseAccessToken>, Parameters<VerifySupabaseAccessToken>>()
71
+ verify.mockResolvedValue(refused(SupabaseErrorCode.AccessTokenInvalid))
72
+ const guard = await build(undefined, verify)
73
+ const { context, request } = contextOf("bearer forged-token")
74
+
75
+ await expect(guard.canActivate(context)).rejects.toThrow(unauthenticated)
76
+
77
+ expect(request.principal).toBeUndefined()
78
+ })
79
+
80
+ it.each([
81
+ ["no authorization header", undefined],
82
+ ["another scheme", "Basic dXNlcjpwYXNz"],
83
+ ["a scheme without a token", "Bearer"],
84
+ ["a token followed by more text", "Bearer one two"],
85
+ ])("refuses a door that is not public with %s, without calling the verifier", async (_label, authorization) => {
86
+ const verify = jest.fn<ReturnType<VerifySupabaseAccessToken>, Parameters<VerifySupabaseAccessToken>>()
87
+ const guard = await build(undefined, verify)
88
+ const { context } = contextOf(authorization)
89
+
90
+ await expect(guard.canActivate(context)).rejects.toThrow(unauthenticated)
91
+
92
+ expect(verify).not.toHaveBeenCalled()
93
+ })
94
+ })
@@ -1,7 +1,6 @@
1
1
  import { Injectable } from "@nestjs/common"
2
2
  import type { CanActivate, ExecutionContext } from "@nestjs/common"
3
3
  import type { Reflector } from "@nestjs/core"
4
- import { InjectSupabaseAccessTokenVerifier } from "@modules/integrations/supabase"
5
4
  import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
6
5
  import { InjectReflector } from "@modules/platform/composition"
7
6
  import type { Principal } from "@modules/platform/cqrs"
@@ -10,7 +9,7 @@ import { unwrapOutcome } from "@modules/platform/primitives"
10
9
  import { admit } from "./admission.policy"
11
10
  import { IdentityError } from "./errors/identity.error"
12
11
  import type { PublicMetadata } from "./identity.contracts"
13
- import { PUBLIC_KEY } from "./identity.decorators"
12
+ import { InjectAccessTokenVerifier, PUBLIC_KEY } from "./identity.decorators"
14
13
 
15
14
  const bearerToken = (authorization: string | undefined): string | undefined => {
16
15
  const [scheme, token, extra] = authorization?.split(" ") ?? []
@@ -22,8 +21,7 @@ const bearerToken = (authorization: string | undefined): string | undefined => {
22
21
  export class AuthGuard implements CanActivate {
23
22
  constructor(
24
23
  @InjectReflector() private readonly reflector: Reflector,
25
- @InjectSupabaseAccessTokenVerifier()
26
- private readonly verifyAccessToken: VerifySupabaseAccessToken,
24
+ @InjectAccessTokenVerifier() private readonly verifyAccessToken: VerifySupabaseAccessToken,
27
25
  ) {}
28
26
 
29
27
  /** Verifies the bearer token when the door is not public, then applies the admission policy. */
@@ -1,10 +1,20 @@
1
1
  import { SetMetadata, createParamDecorator } from "@nestjs/common"
2
2
  import type { ExecutionContext } from "@nestjs/common"
3
+ import { injector } from "@modules/platform/composition"
4
+ import type { TypedParameterDecorator } from "@modules/platform/composition"
5
+ import type { VerifySupabaseAccessToken } from "@modules/integrations/supabase"
3
6
  import { requestOf } from "@modules/platform/http-security"
4
7
  import type { Principal } from "@modules/platform/cqrs"
5
8
  import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
6
9
  import type { PublicMetadata } from "./identity.contracts"
7
10
 
11
+ /** Token of the access-token verifier the composition root hands to identity. */
12
+ export const ACCESS_TOKEN_VERIFIER: unique symbol = Symbol("domain.identity.access-token-verifier")
13
+
14
+ /** Injects the access-token verifier identity was registered with. Parameter type: VerifySupabaseAccessToken. */
15
+ export const InjectAccessTokenVerifier = (): TypedParameterDecorator<VerifySupabaseAccessToken> =>
16
+ injector<VerifySupabaseAccessToken>(ACCESS_TOKEN_VERIFIER)
17
+
8
18
  /** Metadata key of `@Public`. */
9
19
  export const PUBLIC_KEY = "domain.identity.public"
10
20
 
@@ -1,7 +1,7 @@
1
1
  import { Module } from "@nestjs/common"
2
2
  import type { DynamicModule } from "@nestjs/common"
3
- import { SUPABASE_ACCESS_TOKEN_VERIFIER } from "@modules/integrations/supabase"
4
3
  import { AuthGuard } from "./auth.guard"
4
+ import { ACCESS_TOKEN_VERIFIER } from "./identity.decorators"
5
5
  import { ConfigurableModuleClass, OPTIONS_TYPE } from "./identity.module-definition"
6
6
 
7
7
  @Module({})
@@ -15,7 +15,7 @@ export class IdentityModule extends ConfigurableModuleClass {
15
15
  providers: [
16
16
  ...(base.providers ?? []),
17
17
  {
18
- provide: SUPABASE_ACCESS_TOKEN_VERIFIER,
18
+ provide: ACCESS_TOKEN_VERIFIER,
19
19
  useValue: options.verifyAccessToken,
20
20
  },
21
21
  AuthGuard,
@@ -1,10 +1,6 @@
1
1
  export { createSupabaseAdminClient } from "./supabase.client"
2
2
  export { parseSupabaseConfig } from "./supabase.config"
3
- export {
4
- InjectSupabaseAccessTokenVerifier,
5
- SUPABASE_ACCESS_TOKEN_VERIFIER,
6
- SUPABASE_ADMIN_CLIENT,
7
- } from "./supabase.decorators"
8
- export { SUPABASE_ERROR_KINDS } from "./errors/supabase.error"
3
+ export { SUPABASE_ACCESS_TOKEN_VERIFIER, SUPABASE_ADMIN_CLIENT } from "./supabase.decorators"
4
+ export { SUPABASE_ERROR_KINDS, SupabaseErrorCode } from "./errors/supabase.error"
9
5
  export { createSupabaseAccessTokenVerifier } from "./supabase.jwks"
10
6
  export type { SupabaseOptions, VerifySupabaseAccessToken } from "./supabase.options"
@@ -0,0 +1,15 @@
1
+ import { Secret } from "@modules/platform/config"
2
+ import { createSupabaseAdminClient } from "./supabase.client"
3
+
4
+ describe("createSupabaseAdminClient", () => {
5
+ it("exposes only the Auth administration and Storage surfaces of a session-less service client", () => {
6
+ const client = createSupabaseAdminClient({
7
+ url: "http://127.0.0.1:54321",
8
+ serviceRoleKey: new Secret("service-role"),
9
+ })
10
+
11
+ expect(Object.keys(client).sort()).toEqual(["auth", "storage"])
12
+ expect(typeof client.auth.createUser).toBe("function")
13
+ expect(typeof client.storage.from).toBe("function")
14
+ })
15
+ })
@@ -1,4 +1,4 @@
1
1
  export type { Locale, MessageBundle } from "./i18n.contracts"
2
- export { InjectMessageCatalog, InjectRequestLocale } from "./i18n.decorators"
2
+ export { InjectMessageCatalog, InjectRequestLocale, MESSAGE_CATALOG } from "./i18n.decorators"
3
3
  export { I18nModule } from "./i18n.module"
4
4
  export type { MessageCatalog, RequestLocale } from "./i18n.port"
@@ -1,5 +1,5 @@
1
1
  export { createJsonLogger } from "./json-logger.service"
2
- export { InjectLogger } from "./logging.decorators"
2
+ export { InjectLogger, LOGGER } from "./logging.decorators"
3
3
  export { LoggingLogEvent } from "./logging.log-events"
4
4
  export { LoggingModule } from "./logging.module"
5
5
  export type { Logger } from "./logging.port"
@@ -0,0 +1,10 @@
1
+ import { Command } from "@nestjs/cqrs"
2
+ import type { ExecuteParams } from "@modules/platform/cqrs"
3
+ import type { Create{{Singular}}Request, Create{{Singular}}Result } from "./create-{{singular}}.contracts"
4
+
5
+ /** Asks to create one {{singular}} row owned by the authenticated caller. */
6
+ export class Create{{Singular}}Command extends Command<Create{{Singular}}Result> {
7
+ constructor(readonly params: ExecuteParams<Create{{Singular}}Request>) {
8
+ super()
9
+ }
10
+ }
@@ -0,0 +1,8 @@
1
+ /** What create-{{singular}} takes: nothing beyond the caller, who owns the new row. */
2
+ export type Create{{Singular}}Request = Record<never, never>
3
+
4
+ /** What create-{{singular}} answers. */
5
+ export interface Create{{Singular}}Result {
6
+ /** The id of the new {{singular}} row. */
7
+ readonly id: string
8
+ }
@@ -0,0 +1,22 @@
1
+ import { CommandHandler } from "@nestjs/cqrs"
2
+ import { {{Name}}Service } from "@modules/domain/{{name}}"
3
+ import { ICQRSHandler } from "@modules/platform/cqrs"
4
+ import { InjectLogger } from "@modules/platform/logging"
5
+ import type { Logger } from "@modules/platform/logging"
6
+ import { Create{{Singular}}Command } from "./create-{{singular}}.command"
7
+ import type { Create{{Singular}}Result } from "./create-{{singular}}.contracts"
8
+
9
+ @CommandHandler(Create{{Singular}}Command)
10
+ /** Creates one {{singular}} row for the authenticated principal through one domain-service call. */
11
+ export class Create{{Singular}}Handler extends ICQRSHandler<Create{{Singular}}Command, Create{{Singular}}Result> {
12
+ constructor(
13
+ @InjectLogger() logger: Logger,
14
+ private readonly {{nameCamel}}Service: {{Name}}Service,
15
+ ) {
16
+ super(logger)
17
+ }
18
+
19
+ protected override process(command: Create{{Singular}}Command): Promise<Create{{Singular}}Result> {
20
+ return this.{{nameCamel}}Service.create{{Singular}}(command.params.principal.id)
21
+ }
22
+ }
@@ -0,0 +1,5 @@
1
+ import type { Create{{Singular}}Result } from "../../application/create-{{singular}}.contracts"
2
+ import type { Create{{Singular}}Type } from "./dto/create-{{singular}}.type"
3
+
4
+ /** Maps the result to the GraphQL type. */
5
+ export const toCreate{{Singular}}Type = (result: Create{{Singular}}Result): Create{{Singular}}Type => ({ id: result.id })
@@ -0,0 +1,21 @@
1
+ import { Mutation, Resolver } from "@nestjs/graphql"
2
+ import type { CommandBus } from "@nestjs/cqrs"
3
+ import { CurrentPrincipal } from "@modules/domain/identity"
4
+ import { InjectCommandBus } from "@modules/platform/cqrs"
5
+ import type { Principal } from "@modules/platform/cqrs"
6
+ import { Create{{Singular}}Command } from "../../application/create-{{singular}}.command"
7
+ import { toCreate{{Singular}}Type } from "./create-{{singular}}.mapper"
8
+ import { Create{{Singular}}Type } from "./dto/create-{{singular}}.type"
9
+
10
+ @Resolver()
11
+ /** GraphQL door of create-{{singular}}: a new {{singular}} row owned by the caller. */
12
+ export class Create{{Singular}}Resolver {
13
+ constructor(@InjectCommandBus() private readonly commandBus: CommandBus) {}
14
+
15
+ /** Creates one {{singular}} row for the caller. */
16
+ @Mutation(() => Create{{Singular}}Type, { name: "create{{Singular}}" })
17
+ async create{{Singular}}(@CurrentPrincipal() principal: Principal): Promise<Create{{Singular}}Type> {
18
+ const result = await this.commandBus.execute(new Create{{Singular}}Command({ request: {}, principal }))
19
+ return toCreate{{Singular}}Type(result)
20
+ }
21
+ }
@@ -0,0 +1,9 @@
1
+ import { Field, ID, ObjectType } from "@nestjs/graphql"
2
+
3
+ @ObjectType()
4
+ /** The create-{{singular}} answer. */
5
+ export class Create{{Singular}}Type {
6
+ /** The id of the new {{singular}} row. */
7
+ @Field(() => ID)
8
+ id!: string
9
+ }
@@ -0,0 +1 @@
1
+ export { {{Name}}GraphqlModule } from "./transport/graphql/{{name}}-graphql.module"
@@ -0,0 +1,7 @@
1
+ import { Module } from "@nestjs/common"
2
+ import { Create{{Singular}}Handler } from "./application/create-{{singular}}.handler"
3
+ import { Get{{Singular}}Handler } from "./application/get-{{singular}}.handler"
4
+
5
+ @Module({ providers: [Get{{Singular}}Handler, Create{{Singular}}Handler] })
6
+ /** The {{name}} feature: the handlers of its application. */
7
+ export class {{Name}}ApiModule {}
@@ -0,0 +1,11 @@
1
+ /** What get-{{singular}} takes. */
2
+ export interface Get{{Singular}}Request {
3
+ /** The id of the {{singular}} row. */
4
+ readonly id: string
5
+ }
6
+
7
+ /** What get-{{singular}} answers. */
8
+ export interface Get{{Singular}}Result {
9
+ /** The id of the {{singular}} row. */
10
+ readonly id: string
11
+ }
@@ -0,0 +1,22 @@
1
+ import { QueryHandler } from "@nestjs/cqrs"
2
+ import { {{Name}}Service } from "@modules/domain/{{name}}"
3
+ import { ICQRSHandler } from "@modules/platform/cqrs"
4
+ import { InjectLogger } from "@modules/platform/logging"
5
+ import type { Logger } from "@modules/platform/logging"
6
+ import type { Get{{Singular}}Result } from "./get-{{singular}}.contracts"
7
+ import { Get{{Singular}}Query } from "./get-{{singular}}.query"
8
+
9
+ @QueryHandler(Get{{Singular}}Query)
10
+ /** Answers one {{singular}} row of the authenticated principal through one domain-service call. */
11
+ export class Get{{Singular}}Handler extends ICQRSHandler<Get{{Singular}}Query, Get{{Singular}}Result> {
12
+ constructor(
13
+ @InjectLogger() logger: Logger,
14
+ private readonly {{nameCamel}}Service: {{Name}}Service,
15
+ ) {
16
+ super(logger)
17
+ }
18
+
19
+ protected override process(query: Get{{Singular}}Query): Promise<Get{{Singular}}Result> {
20
+ return this.{{nameCamel}}Service.{{nameCamel}}(query.params.principal.id, query.params.request.id)
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ import { Field, ID, InputType } from "@nestjs/graphql"
2
+ import { IsUUID, MaxLength } from "class-validator"
3
+
4
+ @InputType()
5
+ /** The get-{{singular}} arguments: the id of the row. */
6
+ export class Get{{Singular}}Input {
7
+ /** The id of the {{singular}} row. */
8
+ @Field(() => ID)
9
+ @IsUUID()
10
+ @MaxLength(36)
11
+ id!: string
12
+ }