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.
- package/dist/src/create.cjs +18 -1
- package/dist/src/create.cjs.map +1 -1
- package/dist/src/fs.cjs +65 -5
- package/dist/src/fs.cjs.map +1 -1
- package/dist/src/fs.d.cts +13 -2
- package/dist/src/index.cjs +0 -0
- package/dist/src/paths.cjs +2 -2
- package/dist/src/prompts.cjs +22 -0
- package/dist/src/prompts.cjs.map +1 -1
- package/dist/src/runCli.cjs +13 -2
- package/dist/src/runCli.cjs.map +1 -1
- package/dist/src/types.d.cts +9 -1
- package/package.json +8 -6
- package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +46 -3
- package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +15 -1
- package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -0
- package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +98 -193
- package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -173
- package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +133 -240
- package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -113
- package/templates/minimal-app/README.md.tpl +3 -3
- package/templates/minimal-app/apps/.gitignore.tpl +13 -0
- package/templates/minimal-app/apps/e2e/package.json.tpl +23 -0
- package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +39 -0
- package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -0
- package/templates/minimal-app/apps/e2e/servers.ts.tpl +100 -0
- package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -0
- package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -0
- package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -0
- package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -0
- package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -0
- package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -0
- package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -0
- package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -0
- package/templates/minimal-app/apps/email/package.json.tpl +34 -34
- package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +35 -34
- package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +34 -33
- package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +35 -34
- package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +37 -36
- package/templates/minimal-app/apps/email/src/emails/waitlistEmails.tsx.tpl +87 -0
- package/templates/minimal-app/apps/email/src/index.ts.tpl +72 -32
- package/templates/minimal-app/apps/email/src/resources.ts.tpl +65 -36
- package/templates/minimal-app/apps/email/tsconfig.json.tpl +16 -11
- package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -0
- package/templates/minimal-app/apps/expo/.prettierignore +13 -0
- package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -0
- package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -0
- package/templates/minimal-app/apps/expo/README.md.tpl +44 -0
- package/templates/minimal-app/apps/expo/app.json.tpl +36 -0
- package/templates/minimal-app/apps/expo/assets/images/adaptive-icon.png +0 -0
- package/templates/minimal-app/apps/expo/assets/images/favicon.png +0 -0
- package/templates/minimal-app/apps/expo/assets/images/icon.png +0 -0
- package/templates/minimal-app/apps/expo/assets/images/splash-icon.png +0 -0
- package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -0
- package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
- package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -0
- package/templates/minimal-app/apps/expo/package.json.tpl +59 -0
- package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -0
- package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -0
- package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -0
- package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -0
- package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -0
- package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -0
- package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -0
- package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -0
- package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -0
- package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -0
- package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -0
- package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -0
- package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -0
- package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -0
- package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -0
- package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -0
- package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -0
- package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -0
- package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -0
- package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +39 -39
- package/templates/minimal-app/apps/server/drizzle/0000_wealthy_absorbing_man.sql +268 -0
- package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +26 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +1890 -0
- package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +13 -0
- package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +33 -0
- package/templates/minimal-app/apps/server/drizzle/seed.e2e.ts.tpl +244 -0
- package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +181 -180
- package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +14 -21
- package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +33 -35
- package/templates/minimal-app/apps/server/package.json.tpl +56 -52
- package/templates/minimal-app/apps/server/src/app.ts.tpl +142 -126
- package/templates/minimal-app/apps/server/src/index.ts.tpl +58 -58
- package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +12 -12
- package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +25 -25
- package/templates/minimal-app/apps/server/src/modules/posts/posts.dto.ts.tpl +23 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +30 -40
- package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
- package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -112
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +104 -170
- package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -48
- package/templates/minimal-app/apps/server/src/modules/posts/posts.utils.ts.tpl +19 -0
- package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -0
- package/templates/minimal-app/apps/server/src/schema.ts.tpl +20 -0
- package/templates/minimal-app/apps/server/src/types.ts.tpl +1 -1
- package/templates/minimal-app/apps/server/tsconfig.json.tpl +20 -14
- package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -9
- package/templates/minimal-app/apps/shared/package.json.tpl +24 -24
- package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -2
- package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -76
- package/templates/minimal-app/apps/shared/tsconfig.json.tpl +16 -12
- package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
- package/templates/minimal-app/apps/webapp/package.json.tpl +53 -52
- package/templates/minimal-app/apps/webapp/public/{push-sw.js → push-sw.js.tpl} +34 -34
- package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +44 -13
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +18 -12
- package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +33 -40
- package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -75
- package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
- package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +278 -307
- package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +96 -138
- package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +118 -124
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +108 -116
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +15 -9
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -13
- package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -16
- package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
- package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -6
- package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
- package/templates/minimal-app/apps/webapp/translations/{app.json.tpl → en/app.json.tpl} +7 -7
- package/templates/minimal-app/apps/webapp/translations/en/{blog-app.json.tpl → {{APP_SLUG}}.json.tpl} +135 -135
- package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
- package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
- package/templates/minimal-app/template.manifest.json +21 -0
- package/templates/minimal-app/apps/server/drizzle/generate-schema.ts.tpl +0 -31
- package/templates/minimal-app/apps/server/src/db.ts.tpl +0 -7
- package/templates/minimal-app/apps/server/src/generated/schema.ts.tpl +0 -13
- package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +0 -8
- package/templates/minimal-app/apps/shared/src/modules/app/locale.constants.ts.tpl +0 -7
- package/templates/minimal-app/apps/shared/src/modules/app/roles.constants.ts.tpl +0 -17
- package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +0 -36
- package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +0 -89
- package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +0 -85
- /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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
+
});
|