create-m5kdev 0.25.3 → 0.26.1

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 (143) hide show
  1. package/dist/src/create.cjs +18 -1
  2. package/dist/src/create.cjs.map +1 -1
  3. package/dist/src/fs.cjs +65 -5
  4. package/dist/src/fs.cjs.map +1 -1
  5. package/dist/src/fs.d.cts +13 -2
  6. package/dist/src/index.cjs +0 -0
  7. package/dist/src/paths.cjs +2 -2
  8. package/dist/src/prompts.cjs +22 -0
  9. package/dist/src/prompts.cjs.map +1 -1
  10. package/dist/src/runCli.cjs +13 -2
  11. package/dist/src/runCli.cjs.map +1 -1
  12. package/dist/src/types.d.cts +9 -1
  13. package/package.json +8 -6
  14. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +46 -3
  15. package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +15 -1
  16. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -0
  17. package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +98 -193
  18. package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -173
  19. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +133 -240
  20. package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -113
  21. package/templates/minimal-app/README.md.tpl +3 -3
  22. package/templates/minimal-app/apps/.gitignore.tpl +13 -0
  23. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -0
  24. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +39 -0
  25. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -0
  26. package/templates/minimal-app/apps/e2e/servers.ts.tpl +100 -0
  27. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -0
  28. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -0
  29. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -0
  30. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -0
  31. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -0
  32. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -0
  33. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -0
  34. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -0
  35. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  36. package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +35 -34
  37. package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +34 -33
  38. package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +35 -34
  39. package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +37 -36
  40. package/templates/minimal-app/apps/email/src/emails/waitlistEmails.tsx.tpl +87 -0
  41. package/templates/minimal-app/apps/email/src/index.ts.tpl +72 -32
  42. package/templates/minimal-app/apps/email/src/resources.ts.tpl +65 -36
  43. package/templates/minimal-app/apps/email/tsconfig.json.tpl +16 -11
  44. package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -0
  45. package/templates/minimal-app/apps/expo/.prettierignore +13 -0
  46. package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -0
  47. package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -0
  48. package/templates/minimal-app/apps/expo/README.md.tpl +44 -0
  49. package/templates/minimal-app/apps/expo/app.json.tpl +36 -0
  50. package/templates/minimal-app/apps/expo/assets/images/adaptive-icon.png +0 -0
  51. package/templates/minimal-app/apps/expo/assets/images/favicon.png +0 -0
  52. package/templates/minimal-app/apps/expo/assets/images/icon.png +0 -0
  53. package/templates/minimal-app/apps/expo/assets/images/splash-icon.png +0 -0
  54. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -0
  55. package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
  56. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -0
  57. package/templates/minimal-app/apps/expo/package.json.tpl +59 -0
  58. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -0
  59. package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -0
  60. package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -0
  61. package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -0
  62. package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -0
  63. package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -0
  64. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -0
  65. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -0
  66. package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -0
  67. package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -0
  68. package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -0
  69. package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -0
  70. package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -0
  71. package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -0
  72. package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -0
  73. package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -0
  74. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -0
  75. package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -0
  76. package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -0
  77. package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
  78. package/templates/minimal-app/apps/server/AGENTS.md.tpl +39 -39
  79. package/templates/minimal-app/apps/server/drizzle/0000_wealthy_absorbing_man.sql +268 -0
  80. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +26 -0
  81. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +1890 -0
  82. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +13 -0
  83. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +33 -0
  84. package/templates/minimal-app/apps/server/drizzle/seed.e2e.ts.tpl +244 -0
  85. package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +181 -180
  86. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +14 -21
  87. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +33 -35
  88. package/templates/minimal-app/apps/server/package.json.tpl +56 -52
  89. package/templates/minimal-app/apps/server/src/app.ts.tpl +142 -126
  90. package/templates/minimal-app/apps/server/src/index.ts.tpl +58 -58
  91. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +12 -12
  92. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +25 -25
  93. package/templates/minimal-app/apps/server/src/modules/posts/posts.dto.ts.tpl +23 -0
  94. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +30 -40
  95. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
  96. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -112
  97. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +104 -170
  98. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -48
  99. package/templates/minimal-app/apps/server/src/modules/posts/posts.utils.ts.tpl +19 -0
  100. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -0
  101. package/templates/minimal-app/apps/server/src/schema.ts.tpl +20 -0
  102. package/templates/minimal-app/apps/server/src/types.ts.tpl +1 -1
  103. package/templates/minimal-app/apps/server/tsconfig.json.tpl +20 -14
  104. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -9
  105. package/templates/minimal-app/apps/shared/package.json.tpl +24 -24
  106. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -2
  107. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  108. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  109. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -76
  110. package/templates/minimal-app/apps/shared/tsconfig.json.tpl +16 -12
  111. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
  112. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -52
  113. package/templates/minimal-app/apps/webapp/public/{push-sw.js → push-sw.js.tpl} +34 -34
  114. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +44 -13
  115. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +18 -12
  116. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +33 -40
  117. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -75
  118. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  119. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +278 -307
  120. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +96 -138
  121. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +118 -124
  122. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +108 -116
  123. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +15 -9
  124. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -13
  125. package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -16
  126. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  127. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -6
  128. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  129. package/templates/minimal-app/apps/webapp/translations/{app.json.tpl → en/app.json.tpl} +7 -7
  130. package/templates/minimal-app/apps/webapp/translations/en/{blog-app.json.tpl → {{APP_SLUG}}.json.tpl} +135 -135
  131. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  132. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  133. package/templates/minimal-app/template.manifest.json +21 -0
  134. package/templates/minimal-app/apps/server/drizzle/generate-schema.ts.tpl +0 -31
  135. package/templates/minimal-app/apps/server/src/db.ts.tpl +0 -7
  136. package/templates/minimal-app/apps/server/src/generated/schema.ts.tpl +0 -13
  137. package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +0 -8
  138. package/templates/minimal-app/apps/shared/src/modules/app/locale.constants.ts.tpl +0 -7
  139. package/templates/minimal-app/apps/shared/src/modules/app/roles.constants.ts.tpl +0 -17
  140. package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +0 -36
  141. package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +0 -89
  142. package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +0 -85
  143. /package/templates/minimal-app/apps/webapp/public/{logo.svg → logo.svg.tpl} +0 -0
@@ -1,240 +1,133 @@
1
- ---
2
- globs: apps/**/server/src/modules/**/*.service.ts
3
- alwaysApply: false
4
- ---
5
-
6
- # Module Service File Style Guide
7
-
8
- Conventions for service classes in app modules.
9
-
10
- ## File Location
11
-
12
- ```
13
- apps/<app>/server/src/modules/<module>/<module>.service.ts
14
- ```
15
-
16
- ## Conventions
17
-
18
- - Extend `BaseService<Repositories, Services>` or `BasePermissionService<Repositories, Services>` from `@m5kdev/backend/modules/base/base.service`.
19
- - Declare repository and service dependencies as explicit named maps in generics.
20
- - Keep orchestration and business logic in services; repositories handle persistence only.
21
- - Implement service methods as procedures using `this.procedure("name")` builder chains.
22
- - Propagate `ServerResultAsync` consistently and use `err(...)`/`ok(...)` composition.
23
-
24
- ## Schema Type Imports
25
-
26
- Import types from the shared schema file for procedure type parameters:
27
-
28
- ```typescript
29
- import type {
30
- PostCreateInputSchema,
31
- PostCreateOutputSchema,
32
- PostsListInputSchema,
33
- PostsListOutputSchema,
34
- } from "@appname/shared/modules/posts/posts.schema";
35
- ```
36
-
37
- ## Context Type
38
-
39
- Define a `RequestContext` alias from the framework's `Context` type:
40
-
41
- ```typescript
42
- import type { Context } from "@m5kdev/backend/utils/trpc";
43
-
44
- type RequestContext = Context;
45
- ```
46
-
47
- Pass this as the third generic to `BasePermissionService` or `BaseService`.
48
-
49
- ## Procedure Builder
50
-
51
- Service methods are defined using a chainable procedure builder. Each procedure handles input typing, authentication, resource loading, access control, and the final handler.
52
-
53
- ### Builder Methods (in typical call order)
54
-
55
- 1. `.requireAuth(scope?)` — Enforces authentication. Scope: `"user"` (default), `"organization"`, `"team"`, `"admin"`.
56
- 2. `.use(name, step)` — Runs an arbitrary step and stores the result in `state`.
57
- 3. `.loadResource(name, loader)` — Loads a value and stores it in `state`; returns `NOT_FOUND` if falsy.
58
- 4. `.access(config)` — Permission check (only on `BasePermissionService`). Action must match a grant.
59
- 5. `.handle(handler)` — Terminal method; runs the handler with `{ input, ctx, state, repository, service, logger }`.
60
-
61
- ### Input Typing
62
-
63
- Use the generic type parameter with shared schema types:
64
-
65
- ```typescript
66
- readonly create = this.procedure<PostCreateInputSchema>("create")
67
- .requireAuth()
68
- .handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
69
- // ...
70
- });
71
- ```
72
-
73
- ## Procedure Patterns
74
-
75
- ### Simple List
76
-
77
- ```typescript
78
- readonly list = this.procedure<PostsListInputSchema>("list")
79
- .requireAuth()
80
- .handle(({ input }): ServerResultAsync<PostsListOutputSchema> => {
81
- return this.repository.posts.list(input);
82
- });
83
- ```
84
-
85
- ### Create with Business Logic
86
-
87
- ```typescript
88
- readonly create = this.procedure<PostCreateInputSchema>("create")
89
- .requireAuth()
90
- .access({
91
- action: "write",
92
- entities: ({ ctx }) => ({
93
- organizationId: ctx.session.activeOrganizationId ?? null,
94
- teamId: ctx.session.activeTeamId ?? null,
95
- }),
96
- })
97
- .handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
98
- const uniqueSlug = await this.repository.posts.resolveUniqueSlug(
99
- this.slugify(input.slug ?? input.title)
100
- );
101
- if (uniqueSlug.isErr()) return err(uniqueSlug.error);
102
-
103
- return this.repository.posts.create({
104
- authorUserId: ctx.user.id,
105
- organizationId: ctx.session.activeOrganizationId ?? null,
106
- teamId: ctx.session.activeTeamId ?? null,
107
- title: input.title.trim(),
108
- slug: uniqueSlug.value,
109
- content: input.content.trim(),
110
- status: "draft",
111
- }) as ServerResultAsync<PostCreateOutputSchema>;
112
- });
113
- ```
114
-
115
- ### Update with Resource Loading and Access Control
116
-
117
- ```typescript
118
- readonly update = this.procedure<PostUpdateInputSchema>("update")
119
- .requireAuth()
120
- .use("post", async ({ input }) => {
121
- const current = await this.repository.posts.findById(input.id);
122
- if (current.isErr()) return err(current.error);
123
- if (!current.value || current.value.deletedAt) {
124
- return this.error("NOT_FOUND", "Post not found");
125
- }
126
- return current.value;
127
- })
128
- .access({
129
- action: "write",
130
- entityStep: "post",
131
- })
132
- .handle(async ({ input }): ServerResultAsync<PostUpdateOutputSchema> => {
133
- return this.repository.posts.update(input);
134
- });
135
- ```
136
-
137
- ### Soft Delete
138
-
139
- ```typescript
140
- readonly softDelete = this.procedure<PostSoftDeleteInputSchema>("softDelete")
141
- .requireAuth()
142
- .use("post", async ({ input }) => {
143
- const current = await this.repository.posts.findById(input.id);
144
- if (current.isErr()) return err(current.error);
145
- if (!current.value || current.value.deletedAt) {
146
- return this.error("NOT_FOUND", "Post not found");
147
- }
148
- return current.value;
149
- })
150
- .access({
151
- action: "delete",
152
- entityStep: "post",
153
- })
154
- .handle(async ({ input }): ServerResultAsync<PostSoftDeleteOutputSchema> => {
155
- const updated = await this.repository.posts.update({
156
- id: input.id,
157
- deletedAt: new Date(),
158
- });
159
- if (updated.isErr()) return err(updated.error);
160
- return ok({ id: updated.value.id });
161
- });
162
- ```
163
-
164
- ### Public (No Auth)
165
-
166
- ```typescript
167
- readonly getPublic = this.procedure<GetPublicInputSchema>("getPublic")
168
- .handle(async ({ input }): ServerResultAsync<PostSchema> => {
169
- return this.repository.posts.findPublished(input.slug);
170
- });
171
- ```
172
-
173
- ## Access Control Modes
174
-
175
- The `.access()` step supports two entity resolution strategies:
176
-
177
- - **`entityStep`** — References a value stored by a prior `.use()` or `.loadResource()` step. The entity's `userId`, `organizationId`, and `teamId` are read automatically.
178
- - **`entities`** — Inline function returning `{ userId?, organizationId?, teamId? }` for cases where the entity is not loaded into state.
179
-
180
- ## Permission and Grants
181
-
182
- - If extending `BasePermissionService`, keep action names aligned with grants (`read`, `write`, `delete`, `publish`).
183
- - Do not use undeclared action aliases (e.g., guard `"update"` when grants only define `"write"`).
184
- - If a module has no `.access()` usage, prefer `BaseService` over `BasePermissionService`.
185
-
186
- ## Example Skeleton
187
-
188
- ```typescript
189
- import type {
190
- PostCreateInputSchema,
191
- PostCreateOutputSchema,
192
- PostsListInputSchema,
193
- PostsListOutputSchema,
194
- } from "@appname/shared/modules/posts/posts.schema";
195
- import { BasePermissionService } from "@m5kdev/backend/modules/base/base.service";
196
- import type { ServerResultAsync } from "@m5kdev/backend/utils/types";
197
- import type { Context } from "@m5kdev/backend/utils/trpc";
198
- import { err, ok } from "neverthrow";
199
- import type { PostsRepository } from "./posts.repository";
200
-
201
- type RequestContext = Context;
202
-
203
- export class PostsService extends BasePermissionService<
204
- { posts: PostsRepository },
205
- Record<string, never>,
206
- RequestContext
207
- > {
208
- readonly list = this.procedure<PostsListInputSchema>("list")
209
- .requireAuth()
210
- .handle(({ input }): ServerResultAsync<PostsListOutputSchema> => {
211
- return this.repository.posts.list(input);
212
- });
213
-
214
- readonly create = this.procedure<PostCreateInputSchema>("create")
215
- .requireAuth()
216
- .access({
217
- action: "write",
218
- entities: ({ ctx }) => ({
219
- organizationId: ctx.session.activeOrganizationId ?? null,
220
- teamId: ctx.session.activeTeamId ?? null,
221
- }),
222
- })
223
- .handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
224
- return this.repository.posts.create({
225
- authorUserId: ctx.user.id,
226
- title: input.title.trim(),
227
- content: input.content.trim(),
228
- status: "draft",
229
- }) as ServerResultAsync<PostCreateOutputSchema>;
230
- });
231
- }
232
- ```
233
-
234
- ## Best Practices
235
-
236
- 1. Keep procedures as `readonly` class properties for consistent initialization.
237
- 2. Use `.use()` steps for resource loading with validation before `.access()`.
238
- 3. Return `this.error("NOT_FOUND")` or `this.error("FORBIDDEN")` for expected failures.
239
- 4. Keep private helper methods (slugify, excerpt generation) as regular class methods.
240
- 5. Propagate `err(...)` from repository results rather than throwing.
1
+ ---
2
+ globs: apps/**/server/src/modules/**/*.service.ts
3
+ alwaysApply: false
4
+ ---
5
+
6
+ # Module Service File Style Guide
7
+
8
+ Conventions for service classes in app modules. Services consume the module
9
+ DTO object (`postSchemas`) — they do not import hand-written request/response
10
+ types. Input and output schemas are attached to each procedure and validated
11
+ at runtime.
12
+
13
+ ## File Location
14
+
15
+ ```
16
+ apps/<app>/server/src/modules/<module>/<module>.service.ts
17
+ ```
18
+
19
+ ## Conventions
20
+
21
+ - Extend `BaseService<Repositories, Services>` or `BasePermissionService<Repositories, Services, Context>` from `@m5kdev/backend/modules/base/base.service`.
22
+ - Declare repository and service dependencies as explicit named maps in generics.
23
+ - Keep orchestration and business logic in services; repositories handle persistence only.
24
+ - Implement service methods as `readonly` procedures using the `this.procedure("name")` builder.
25
+ - Propagate `ServerResultAsync` consistently with `err(...)`/`ok(...)` composition.
26
+
27
+ ## Procedure Builder
28
+
29
+ Each procedure declares its schemas from the DTO, then auth, resource loading,
30
+ access control, and the handler:
31
+
32
+ 1. `.input(schema)` / `.output(schema)` — DTO schemas; both are validated at runtime.
33
+ 2. `.requireAuth(scope?)` — `"user"` (default), `"organization"`, `"team"`, `"admin"`.
34
+ 3. `.loadResource(name, loader)` — loads a value into `state`; returns `NOT_FOUND` when falsy.
35
+ 4. `.use(name, step)` — arbitrary step storing its result in `state`.
36
+ 5. `.access(config)` — permission check (`BasePermissionService` only); action must match a grant.
37
+ 6. `.handle(handler)` — terminal; receives `{ input, ctx, state }`.
38
+
39
+ ## Procedure Patterns
40
+
41
+ ### List — delegate to `queryList`, never hand-roll list logic
42
+
43
+ ```typescript
44
+ readonly list = this.procedure("list")
45
+ .input(postSchemas.input.list)
46
+ .output(postSchemas.output.list)
47
+ .requireAuth()
48
+ .handle(({ input }) => {
49
+ return this.repository.posts.queryList(input, {
50
+ globalSearchColumns: ["title", "excerpt", "content"],
51
+ });
52
+ });
53
+ ```
54
+
55
+ ### Create with business logic
56
+
57
+ ```typescript
58
+ readonly create = this.procedure("create")
59
+ .requireAuth()
60
+ .input(postSchemas.input.create)
61
+ .output(postSchemas.output.single)
62
+ .access({ action: "write" })
63
+ .handle(async ({ input, ctx }) => {
64
+ const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
65
+ candidate: slugify(input.slug ?? input.title),
66
+ });
67
+ if (uniqueSlug.isErr()) return err(uniqueSlug.error);
68
+
69
+ return this.repository.posts.create({
70
+ authorUserId: ctx.user.id,
71
+ organizationId: ctx.session.activeOrganizationId ?? null,
72
+ title: input.title.trim(),
73
+ slug: uniqueSlug.value,
74
+ content: input.content.trim(),
75
+ status: "draft",
76
+ });
77
+ });
78
+ ```
79
+
80
+ ### Update with resource loading and access control
81
+
82
+ ```typescript
83
+ readonly update = this.procedure("update")
84
+ .requireAuth()
85
+ .input(postSchemas.input.update)
86
+ .output(postSchemas.output.single)
87
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
88
+ .access({ action: "write", entityStep: "post" })
89
+ .handle(async ({ input, state }) => {
90
+ return this.repository.posts.update({
91
+ id: input.id,
92
+ title: input.title ? input.title.trim() : state.post.title,
93
+ content: (input.content?.trim() ?? state.post.content).trim(),
94
+ });
95
+ });
96
+ ```
97
+
98
+ ### Soft delete
99
+
100
+ ```typescript
101
+ readonly softDelete = this.procedure("softDelete")
102
+ .input(postSchemas.input.delete)
103
+ .output(postSchemas.output.uuid)
104
+ .requireAuth()
105
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
106
+ .access({ action: "delete", entityStep: "post" })
107
+ .handle(async ({ input }) => {
108
+ const updated = await this.repository.posts.update({ id: input.id, deletedAt: new Date() });
109
+ if (updated.isErr()) return err(updated.error);
110
+ return ok({ id: updated.value.id });
111
+ });
112
+ ```
113
+
114
+ ## Access Control Modes
115
+
116
+ - **`entityStep`** — references a value stored by `.loadResource()`/`.use()`;
117
+ the entity's `userId`, `organizationId`, and `teamId` are read automatically.
118
+ - **`entities`** — inline function returning `{ userId?, organizationId?, teamId? }`
119
+ when the entity is not loaded into state.
120
+
121
+ ## Permissions and Grants
122
+
123
+ - Keep action names aligned with grants (`read`, `write`, `delete`, `publish`).
124
+ - Do not use undeclared aliases (e.g. guarding `"update"` when grants define `"write"`).
125
+ - If a module never uses `.access()`, extend `BaseService` instead of `BasePermissionService`.
126
+
127
+ ## Best Practices
128
+
129
+ 1. Keep procedures as `readonly` class properties.
130
+ 2. `.loadResource()` before `.access({ entityStep })` — it 404s on missing rows for free.
131
+ 3. Return `this.error("NOT_FOUND")` / `this.error("FORBIDDEN")` for expected failures; reserve 5xx codes for server faults (they report to Sentry).
132
+ 4. Keep pure helpers (slugify, excerpt) in `<module>.utils.ts`.
133
+ 5. Propagate `err(...)` from repository results rather than throwing.
@@ -1,113 +1,71 @@
1
- ---
2
- globs: apps/**/server/src/modules/**/*.trpc.ts
3
- alwaysApply: false
4
- ---
5
-
6
- # Module tRPC File Style Guide
7
-
8
- Conventions for tRPC router files in app modules.
9
-
10
- ## File Location
11
-
12
- ```
13
- apps/<app>/server/src/modules/<module>/<module>.trpc.ts
14
- ```
15
-
16
- ## Conventions
17
-
18
- - Export a factory function named `create<Module>TRPC`.
19
- - Import input and output schemas from the shared schema file: `@appname/shared/modules/<module>/<module>.schema`.
20
- - Use `handleTRPCResult` from `@m5kdev/backend/utils/trpc` to unwrap `Result` types.
21
- - Separate queries and mutations; keep resolvers thin and delegate to the service.
22
- - Always pass `ctx` to service methods as the second argument. The first argument (`input`) can be `undefined`.
23
- - Use `z` inline only for simple ad-hoc inputs with primitives (`z.string()`, `z.number()`, `z.array()` with primitive value, `z.record()`); prefer shared schemas otherwise.
24
- - Do not perform business logic or DB access directly in tRPC resolvers.
25
- - To turn a schema into its array version, use `schema.array()` instead of `z.array(schema)`.
26
-
27
- ## Factory Pattern
28
-
29
- The factory function receives `TRPCMethods` (destructured) and the service instance:
30
-
31
- ```typescript
32
- import {
33
- postCreateInputSchema,
34
- postCreateOutputSchema,
35
- postsListInputSchema,
36
- postsListOutputSchema,
37
- postUpdateInputSchema,
38
- postUpdateOutputSchema,
39
- postPublishInputSchema,
40
- postPublishOutputSchema,
41
- postSoftDeleteInputSchema,
42
- postSoftDeleteOutputSchema,
43
- } from "@appname/shared/modules/posts/posts.schema";
44
- import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
45
- import type { PostsService } from "./posts.service";
46
-
47
- export function createPostsTRPC(
48
- { router, privateProcedure: procedure }: TRPCMethods,
49
- postsService: PostsService
50
- ) {
51
- return router({
52
- list: procedure
53
- .input(postsListInputSchema)
54
- .output(postsListOutputSchema)
55
- .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
56
-
57
- create: procedure
58
- .input(postCreateInputSchema)
59
- .output(postCreateOutputSchema)
60
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
61
-
62
- update: procedure
63
- .input(postUpdateInputSchema)
64
- .output(postUpdateOutputSchema)
65
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
66
-
67
- publish: procedure
68
- .input(postPublishInputSchema)
69
- .output(postPublishOutputSchema)
70
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
71
-
72
- softDelete: procedure
73
- .input(postSoftDeleteInputSchema)
74
- .output(postSoftDeleteOutputSchema)
75
- .mutation(async ({ ctx, input }) =>
76
- handleTRPCResult(await postsService.softDelete(input, ctx))
77
- ),
78
- });
79
- }
80
- ```
81
-
82
- ## Available Procedure Types
83
-
84
- Destructure from `TRPCMethods` based on auth requirements:
85
-
86
- | Procedure | Auth | Use for |
87
- |---|---|---|
88
- | `publicProcedure` | None | Public endpoints |
89
- | `privateProcedure` | User session required | Most CRUD operations |
90
- | `adminProcedure` | Admin role required | Admin-only operations |
91
- | `organizationProcedure` | Organization context required | Org-scoped operations |
92
-
93
- Alias `privateProcedure` as `procedure` for conciseness when most routes are authenticated.
94
-
95
- ## Wiring to Module
96
-
97
- The factory is called in the module's `trpc()` hook:
98
-
99
- ```typescript
100
- override trpc({ trpc, services }: ModuleTRPCContext<Deps, Services>) {
101
- return createBackendRouterMap("posts", createPostsTRPC(trpc, services.posts));
102
- }
103
- ```
104
-
105
- The namespace string (`"posts"`) becomes the tRPC path prefix (e.g., `trpc.posts.list`).
106
-
107
- ## Best Practices
108
-
109
- 1. Keep resolvers one-liners: `handleTRPCResult(await service.method(input, ctx))`.
110
- 2. Never access repositories or database directly in tRPC files.
111
- 3. Use shared schemas for `.input()` and `.output()` to maintain contract consistency.
112
- 4. For optional input, pass a default value: `input ?? {}`.
113
- 5. Use `.query()` for read operations and `.mutation()` for writes.
1
+ ---
2
+ globs: apps/**/server/src/modules/**/*.trpc.ts
3
+ alwaysApply: false
4
+ ---
5
+
6
+ # Module tRPC File Style Guide
7
+
8
+ Conventions for tRPC router files in app modules. Routers are thin wiring:
9
+ they attach the module DTO schemas and delegate to the service. The router is
10
+ also the type source for the client — the webapp infers all row/list types
11
+ from `AppRouter`, so accurate `.input()`/`.output()` here is what makes
12
+ end-to-end types work.
13
+
14
+ ## File Location
15
+
16
+ ```
17
+ apps/<app>/server/src/modules/<module>/<module>.trpc.ts
18
+ ```
19
+
20
+ ## Conventions
21
+
22
+ - Export a factory function named `create<Module>TRPC`.
23
+ - Import the module DTO object (`postSchemas`) from `./<module>.dto` — not
24
+ shared schemas, not hand-written types.
25
+ - Use `handleTRPCResult` from `@m5kdev/backend/utils/trpc` to unwrap `Result`s.
26
+ - Keep resolvers one-liners that delegate to the service; no business logic or
27
+ DB access here.
28
+ - Always pass `ctx` to service methods as the second argument. The first
29
+ argument (`input`) can be `undefined` for optional-input queries.
30
+
31
+ ## Factory Pattern
32
+
33
+ ```typescript
34
+ import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
35
+ import { postSchemas } from "./posts.dto";
36
+ import type { PostsService } from "./posts.service";
37
+
38
+ export function createPostsTRPC(
39
+ { router, privateProcedure: procedure }: TRPCMethods,
40
+ postsService: PostsService
41
+ ) {
42
+ return router({
43
+ list: procedure
44
+ .input(postSchemas.input.list)
45
+ .output(postSchemas.output.list)
46
+ .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
47
+
48
+ create: procedure
49
+ .input(postSchemas.input.create)
50
+ .output(postSchemas.output.single)
51
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
52
+
53
+ softDelete: procedure
54
+ .input(postSchemas.input.delete)
55
+ .output(postSchemas.output.uuid)
56
+ .mutation(async ({ ctx, input }) =>
57
+ handleTRPCResult(await postsService.softDelete(input, ctx))
58
+ ),
59
+ });
60
+ }
61
+ ```
62
+
63
+ ## Procedure Selection
64
+
65
+ - `privateProcedure` — authenticated user (most module routes).
66
+ - `organizationProcedure` — requires an active organization.
67
+ - `adminProcedure` — admin role.
68
+ - `publicProcedure` — unauthenticated; use sparingly.
69
+
70
+ The service's own `.requireAuth()`/`.access()` still run — the tRPC procedure
71
+ choice is transport-level gating, not a replacement for service checks.
@@ -13,13 +13,13 @@
13
13
 
14
14
  ```sh
15
15
  pnpm install
16
- pnpm --filter ./apps/server sync
17
- pnpm --filter ./apps/server seed
16
+ pnpm --filter ./apps/server drizzle:migrate
17
+ pnpm --filter ./apps/server drizzle:seed
18
18
  pnpm dev
19
19
  ```
20
20
 
21
21
  The starter uses a local LibSQL file by default and writes local auth emails to `apps/server/.emails`.
22
- Before running `drizzle-kit`, the starter generates `apps/server/src/generated/schema.ts` from the registered backend modules.
22
+ Database tables are registered by hand in `apps/server/src/schema.ts`; after changing any `*.db.ts`, run `drizzle:generate` and `drizzle:migrate`.
23
23
 
24
24
  ## Demo Credentials
25
25
 
@@ -0,0 +1,13 @@
1
+ # runtime artifacts
2
+ server/local.db*
3
+ server/.e2e/
4
+ server/.emails/
5
+ server/uploads/
6
+
7
+ # e2e output
8
+ e2e/test-results/
9
+ e2e/playwright-report/
10
+
11
+ # expo
12
+ expo/.expo/
13
+ expo/dist/
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "{{PACKAGE_SCOPE}}/e2e",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "build": "tsc --noEmit",
7
+ "lint": "biome check .",
8
+ "lint:fix": "biome check . --write",
9
+ "check-types": "tsc --noEmit",
10
+ "test:e2e": "playwright test",
11
+ "test:e2e:expo": "playwright test --config playwright.expo.config.ts --headed"
12
+ },
13
+ "devDependencies": {
14
+ "@m5kdev/backend": "catalog:",
15
+ "@m5kdev/commons": "catalog:",
16
+ "@m5kdev/config": "catalog:",
17
+ "@playwright/test": "catalog:",
18
+ "{{PACKAGE_SCOPE}}/shared": "workspace:*",
19
+ "@trpc/client": "catalog:",
20
+ "@types/node": "catalog:",
21
+ "typescript": "catalog:"
22
+ }
23
+ }
@@ -0,0 +1,39 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+ import { BASE_URLS, serverWebServer, webappWebServer } from "./servers";
3
+
4
+ export default defineConfig({
5
+ testDir: "./tests",
6
+ fullyParallel: false,
7
+ workers: 1,
8
+ forbidOnly: Boolean(process.env.CI),
9
+ retries: process.env.CI ? 2 : 0,
10
+ reporter: [["list"], ["html", { open: "never" }]],
11
+ use: {
12
+ trace: "on-first-retry",
13
+ },
14
+ webServer: [
15
+ serverWebServer("standard"),
16
+ webappWebServer("standard"),
17
+ serverWebServer("waitlist"),
18
+ webappWebServer("waitlist"),
19
+ ],
20
+ projects: [
21
+ {
22
+ name: "standard",
23
+ // exact filenames — a broad regex would also match the expo-*.spec files
24
+ testMatch: ["standard.spec.ts", "email.spec.ts"],
25
+ use: {
26
+ ...devices["Desktop Chrome"],
27
+ baseURL: BASE_URLS.standard,
28
+ },
29
+ },
30
+ {
31
+ name: "waitlist",
32
+ testMatch: ["waitlist.spec.ts", "email.spec.ts"],
33
+ use: {
34
+ ...devices["Desktop Chrome"],
35
+ baseURL: BASE_URLS.waitlist,
36
+ },
37
+ },
38
+ ],
39
+ });