create-m5kdev 0.41.0 → 0.41.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/package.json +1 -1
- package/templates/minimal-app/.cursor/rules/frontend-component-guide.mdc +95 -95
- package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +103 -103
- package/templates/minimal-app/.cursor/rules/frontend-form-guide.mdc +80 -80
- package/templates/minimal-app/.cursor/rules/frontend-hook-guide.mdc +79 -79
- package/templates/minimal-app/.cursor/rules/frontend-i18n-guide.mdc +45 -45
- package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -60
- package/templates/minimal-app/.cursor/rules/module-prompt-guide.mdc +214 -214
- package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +167 -167
- package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -53
- package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +367 -367
- package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -71
- package/templates/minimal-app/.github/workflows/check.yml.tpl +21 -0
- package/templates/minimal-app/AGENTS.md.tpl +22 -31
- package/templates/minimal-app/CLAUDE.md.tpl +15 -1
- package/templates/minimal-app/GLOSSARY.md.tpl +181 -0
- package/templates/minimal-app/README.md.tpl +2 -0
- package/templates/minimal-app/apps/.gitignore.tpl +13 -13
- package/templates/minimal-app/apps/e2e/package.json.tpl +23 -23
- package/templates/minimal-app/apps/email/package.json.tpl +34 -34
- package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -44
- package/templates/minimal-app/apps/expo/.prettierignore +13 -13
- package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -9
- package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -156
- package/templates/minimal-app/apps/expo/README.md.tpl +44 -44
- package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
- package/templates/minimal-app/apps/expo/package.json.tpl +63 -63
- package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
- package/templates/minimal-app/apps/landing/package.json.tpl +34 -34
- package/templates/minimal-app/apps/server/drizzle/0000_whole_echo.sql +366 -366
- package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +46 -46
- package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +3 -3
- package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +98 -98
- package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +1 -1
- package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +158 -158
- package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +1 -1
- package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +86 -86
- package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2624 -2624
- package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2670 -2670
- package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2715 -2715
- package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +2687 -2687
- package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2497 -2497
- package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2483 -2483
- package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3602 -3602
- package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3619 -3619
- package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3619 -3619
- package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +76 -76
- package/templates/minimal-app/apps/server/package.json.tpl +57 -57
- package/templates/minimal-app/apps/server/src/app.ts.tpl +1 -0
- package/templates/minimal-app/apps/shared/package.json.tpl +27 -27
- package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +24 -24
- package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
- package/templates/minimal-app/apps/webapp/package.json.tpl +53 -53
- package/templates/minimal-app/apps/webapp/public/logo.svg.tpl +6 -6
- package/templates/minimal-app/biome.json.tpl +1 -0
- package/templates/minimal-app/docs/adr/.gitkeep +0 -0
- package/templates/minimal-app/docs/agents/domain.md.tpl +38 -0
- package/templates/minimal-app/docs/agents/issue-tracker.md.tpl +45 -0
- package/templates/minimal-app/docs/agents/triage-labels.md.tpl +15 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +12 -12
- package/templates/minimal-app/template.manifest.json +12 -0
|
@@ -1,367 +1,367 @@
|
|
|
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.
|
|
11
|
-
|
|
12
|
-
## File Location
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
apps/<app>/server/src/modules/<module>/<module>.service.ts
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## Conventions
|
|
19
|
-
|
|
20
|
-
- Always extend `BasePermissionService<Repositories, Services>`.
|
|
21
|
-
- Declare repository and service dependencies as explicit named maps in generics.
|
|
22
|
-
- Instantiate service classes in `apps/*/server/src/service.ts` (composition root), not inside module files.
|
|
23
|
-
- Keep orchestration/business logic in service; repositories handle persistence only.
|
|
24
|
-
- Implement service methods as `readonly` procedures using `this.procedure("name")` builder chains.
|
|
25
|
-
- Propagate `ServerResultAsync` consistently and use `err(...)`/`ok(...)` composition.
|
|
26
|
-
- Prefer helper methods for repeated workflows (prompt prep, query assembly, enrichment).
|
|
27
|
-
- Define grants in `<module>.grants.ts` and wire them when composing the service.
|
|
28
|
-
- **Strongly prefer repository built-ins** (`queryList`, `queryFind`, `findById`, `create`, `update`, `deleteById`, …) over custom repository methods.
|
|
29
|
-
|
|
30
|
-
## Prefer Repository Built-ins
|
|
31
|
-
|
|
32
|
-
Call `BaseTableRepository` helpers from the service. Do **not** invent repository wrappers for ordinary CRUD, lists, or lookups.
|
|
33
|
-
|
|
34
|
-
- **Reads**: use `queryList` / `queryFind` with `filters` (and `addContextFilter` when org/member scoped). Usually no custom `list*` / `get*` / `findBy*` repository methods.
|
|
35
|
-
- **Creates**: `create` is permissive — stamp defaults and actor fields in the service (`memberId`, `organizationId`, `status: "draft"`, …) and pass the full row payload. No custom `create*` repo method for defaults.
|
|
36
|
-
- **Updates**: `update` accepts partial fields — only pass columns that should change. No custom `update*` repo method to omit fields or merge patches.
|
|
37
|
-
- Add a custom repository method only for joins, CTEs, multi-table writes, transactions, or predicates that cannot be expressed with filters / `TableConditionBuilder`.
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
// Preferred: built-in + filters / stamped defaults in the service
|
|
41
|
-
readonly listPublished = this.procedure("listPublished")
|
|
42
|
-
.input(postSchemas.input.list)
|
|
43
|
-
.output(postSchemas.output.list)
|
|
44
|
-
.requireAuth("organization")
|
|
45
|
-
.addContextFilter(["organization"])
|
|
46
|
-
.addFilters(() => [
|
|
47
|
-
{
|
|
48
|
-
columnId: "status",
|
|
49
|
-
type: "enum",
|
|
50
|
-
method: "equals",
|
|
51
|
-
value: "published",
|
|
52
|
-
},
|
|
53
|
-
])
|
|
54
|
-
.access({ action: "read" })
|
|
55
|
-
.handle(({ input }) =>
|
|
56
|
-
this.repository.posts.queryList(input, {
|
|
57
|
-
globalSearchColumns: ["title", "excerpt", "content"],
|
|
58
|
-
})
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
readonly create = this.procedure("create")
|
|
62
|
-
.input(postSchemas.input.create)
|
|
63
|
-
.output(postSchemas.output.single)
|
|
64
|
-
.requireAuth("organization")
|
|
65
|
-
.access({ action: "write" })
|
|
66
|
-
.handle(async ({ input, ctx }) => {
|
|
67
|
-
return this.repository.posts.create({
|
|
68
|
-
...input,
|
|
69
|
-
memberId: ctx.actor.memberId,
|
|
70
|
-
organizationId: ctx.session.activeOrganizationId,
|
|
71
|
-
status: "draft",
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
readonly update = this.procedure("update")
|
|
76
|
-
.input(postSchemas.input.update)
|
|
77
|
-
.output(postSchemas.output.single)
|
|
78
|
-
.requireAuth("organization")
|
|
79
|
-
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
80
|
-
.access({ action: "write", entityStep: "post" })
|
|
81
|
-
.handle(async ({ input }) => {
|
|
82
|
-
// Partial update — only fields present on input are written
|
|
83
|
-
return this.repository.posts.update(input);
|
|
84
|
-
});
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
## Avoid Trivial Service Delegation
|
|
88
|
-
|
|
89
|
-
- Do not add a service method whose only behavior is calling another service.
|
|
90
|
-
- Call the owning service directly when no business rule, authorization, validation, orchestration, transaction boundary, or meaningful domain translation is added.
|
|
91
|
-
- Renaming the dependency method, repackaging arguments, constructing a prompt, or forwarding actor/context data alone does not justify a wrapper.
|
|
92
|
-
- A delegating method is justified only when the current service genuinely owns the use case and adds behavior or protects a boundary callers must not bypass.
|
|
93
|
-
- If removing the method and calling the dependency directly would lose nothing, do not add the method.
|
|
94
|
-
|
|
95
|
-
## Procedure Builder
|
|
96
|
-
|
|
97
|
-
Each procedure declares its schemas from the DTO, then auth, resource loading,
|
|
98
|
-
access control, and the handler:
|
|
99
|
-
|
|
100
|
-
1. `.input(schema)` -- Accepts a Zod schema; infers `TInput` so you don't need `this.procedure<Type>(...)`. No runtime validation by default; pass `true` as second arg to enable it.
|
|
101
|
-
2. `.output(schema)` -- Accepts a Zod schema; constrains the handler return type so you don't need explicit return annotations. No runtime validation by default; pass `true` as second arg to enable it.
|
|
102
|
-
3. `.requireAuth(scope?)` -- Enforces authentication. Scope can be `"user"` (default), `"organization" (recommended)`, `"team"`, or `"admin"`.
|
|
103
|
-
4. `.loadResource(name, loader)` -- Loads a value and stores it in `state`; returns `NOT_FOUND` if falsy.
|
|
104
|
-
5. `.use(name, step)` -- Runs an arbitrary step and stores the result in `state`.
|
|
105
|
-
6. `.addContextFilter(scopes?)` -- Applies context-based query filtering (requires auth).
|
|
106
|
-
7. `.addFilters(resolve)` -- Appends query filters to the current input (`({ input, ctx, state, ... }) => QueryFilter | QueryFilter[]`).
|
|
107
|
-
8. `.access(config)` -- Permission check via grants. Arrays are soft-filtered to allowed rows (empty OK); single entities return `FORBIDDEN` when denied. Filtered arrays are written back to `entityStep` state.
|
|
108
|
-
9. `.handle(handler)` -- Terminal method; runs the handler with `{ input, ctx, state, repository, service, logger }`.
|
|
109
|
-
|
|
110
|
-
### Input / Output Typing
|
|
111
|
-
|
|
112
|
-
Prefer `.input(schema)` with Zod schemas from the module's `.dto.ts`:
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
// Preferred: schema-driven
|
|
116
|
-
readonly create = this.procedure("create")
|
|
117
|
-
.input(postSchemas.input.create)
|
|
118
|
-
.output(postSchemas.output.single)
|
|
119
|
-
.requireAuth("organization")
|
|
120
|
-
.access({ action: "write" })
|
|
121
|
-
.handle(async ({ input, ctx }) => {
|
|
122
|
-
return this.repository.posts.create({
|
|
123
|
-
...input,
|
|
124
|
-
userId: ctx.actor.userId,
|
|
125
|
-
memberId: ctx.actor.memberId,
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Acceptable for simple primitives with no matching schema
|
|
130
|
-
readonly getCount = this.procedure("getCount")
|
|
131
|
-
.requireAuth("organization")
|
|
132
|
-
.access({ action: "read" })
|
|
133
|
-
.handle(async ({ ctx }): ServerResultAsync<number> => {
|
|
134
|
-
return this.repository.posts.countByUser(ctx.actor.userId);
|
|
135
|
-
});
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
When no Zod schema exists (simple primitives, inline objects), you can still use the generic: `this.procedure<string>("name")`.
|
|
139
|
-
|
|
140
|
-
## Procedure Patterns
|
|
141
|
-
|
|
142
|
-
### Simple CRUD
|
|
143
|
-
|
|
144
|
-
```typescript
|
|
145
|
-
readonly list = this.procedure("list")
|
|
146
|
-
.input(postSchemas.input.list)
|
|
147
|
-
.output(postSchemas.output.list)
|
|
148
|
-
.requireAuth("organization")
|
|
149
|
-
.addContextFilter(["organization"])
|
|
150
|
-
.loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
|
|
151
|
-
.access({
|
|
152
|
-
action: "read",
|
|
153
|
-
entityStep: "posts",
|
|
154
|
-
})
|
|
155
|
-
.handle(({ state }) => state.posts);
|
|
156
|
-
|
|
157
|
-
readonly create = this.procedure("create")
|
|
158
|
-
.input(postSchemas.input.create)
|
|
159
|
-
.output(postSchemas.output.single)
|
|
160
|
-
.requireAuth("organization")
|
|
161
|
-
.access({ action: "write" })
|
|
162
|
-
.handle(async ({ input, ctx }) => {
|
|
163
|
-
return this.repository.posts.create({
|
|
164
|
-
...input,
|
|
165
|
-
userId: ctx.actor.userId,
|
|
166
|
-
memberId: ctx.actor.memberId,
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
### With Resource Loading and Access Control (recommended)
|
|
172
|
-
|
|
173
|
-
```typescript
|
|
174
|
-
readonly update = this.procedure("update")
|
|
175
|
-
.input(postSchemas.input.update)
|
|
176
|
-
.output(postSchemas.output.single)
|
|
177
|
-
.requireAuth("organization")
|
|
178
|
-
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
179
|
-
.access({
|
|
180
|
-
action: "write",
|
|
181
|
-
entityStep: "post",
|
|
182
|
-
})
|
|
183
|
-
.handle(async ({ input }) => {
|
|
184
|
-
return this.repository.posts.update(input);
|
|
185
|
-
});
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
Use grants with `"org"` (org admin/owner) or `"own"` (member) so `findById` cannot update cross-org rows. Do not use `"all"` for organization-scoped roles.
|
|
189
|
-
|
|
190
|
-
### Org-scoped list with access soft-filter
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
readonly list = this.procedure("list")
|
|
194
|
-
.input(postSchemas.input.list)
|
|
195
|
-
.output(postSchemas.output.list)
|
|
196
|
-
.requireAuth("organization")
|
|
197
|
-
.addContextFilter(["organization"])
|
|
198
|
-
.loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
|
|
199
|
-
.access({
|
|
200
|
-
action: "read",
|
|
201
|
-
entityStep: "posts",
|
|
202
|
-
})
|
|
203
|
-
.handle(({ state }) => state.posts);
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
- Default list scoping: `addContextFilter(["organization"])`.
|
|
207
|
-
- When grants are member-own only, prefer `addContextFilter(["member", "organization"])` so the query is already narrowed.
|
|
208
|
-
- `.access` on an **array** soft-filters to allowed rows (may be `[]`); it does not 403 the whole list.
|
|
209
|
-
- Filtered results are written back to `state[entityStep]` (here `state.posts`).
|
|
210
|
-
|
|
211
|
-
### With Context Filter (Query-Based Lists)
|
|
212
|
-
|
|
213
|
-
```typescript
|
|
214
|
-
readonly list = this.procedure("list")
|
|
215
|
-
.output(postSchemas.output.list)
|
|
216
|
-
.requireAuth("organization")
|
|
217
|
-
.addContextFilter(["member", "organization"])
|
|
218
|
-
.access({ action: "read" })
|
|
219
|
-
.handle(({ input }) => {
|
|
220
|
-
return this.repository.posts.queryList(input, {
|
|
221
|
-
globalSearchColumns: ["title", "excerpt", "content"],
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
### Create with business logic
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
readonly create = this.procedure("create")
|
|
230
|
-
.requireAuth("organization")
|
|
231
|
-
.input(postSchemas.input.create)
|
|
232
|
-
.output(postSchemas.output.single)
|
|
233
|
-
.access({ action: "write" })
|
|
234
|
-
.handle(async ({ input, ctx }) => {
|
|
235
|
-
const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
|
|
236
|
-
candidate: slugify(input.slug ?? input.title),
|
|
237
|
-
});
|
|
238
|
-
if (uniqueSlug.isErr()) return err(uniqueSlug.error);
|
|
239
|
-
|
|
240
|
-
return this.repository.posts.create({
|
|
241
|
-
// Org-scoped assets: stamp memberId (+ organizationId). Personal resources: stamp userId.
|
|
242
|
-
memberId: ctx.actor.memberId,
|
|
243
|
-
organizationId: ctx.actor.organizationId,
|
|
244
|
-
title: input.title.trim(),
|
|
245
|
-
slug: uniqueSlug.value,
|
|
246
|
-
content: input.content.trim(),
|
|
247
|
-
status: "draft",
|
|
248
|
-
});
|
|
249
|
-
});
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
### Soft delete
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
readonly softDelete = this.procedure("softDelete")
|
|
256
|
-
.input(postSchemas.input.delete)
|
|
257
|
-
.output(postSchemas.output.uuid)
|
|
258
|
-
.requireAuth("organization")
|
|
259
|
-
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
260
|
-
.access({ action: "delete", entityStep: "post" })
|
|
261
|
-
.handle(async ({ input }) => {
|
|
262
|
-
const updated = await this.repository.posts.update({
|
|
263
|
-
id: input.id,
|
|
264
|
-
deletedAt: new Date(),
|
|
265
|
-
});
|
|
266
|
-
if (updated.isErr()) return err(updated.error);
|
|
267
|
-
return ok({ id: updated.value.id });
|
|
268
|
-
});
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### Public (No Auth)
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
readonly validateCode = this.procedure("validateCode")
|
|
275
|
-
.input(postSchemas.input.validate)
|
|
276
|
-
.output(postSchemas.output.status)
|
|
277
|
-
.handle(async ({ input }) => {
|
|
278
|
-
return this.repository.posts.validateCode(input);
|
|
279
|
-
});
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
## Permission and Grants
|
|
283
|
-
|
|
284
|
-
- Keep all guarded action names aligned with grants (`read`, `write`, `delete`, `publish`, etc.).
|
|
285
|
-
- Do not use undeclared action aliases (e.g., guard `"update"` when grants only define `"write"`).
|
|
286
|
-
- Prefer `.access({ action, entityStep? })` over ad-hoc ownership checks in `.use`.
|
|
287
|
-
- For org-scoped assets, stamp and authorize with `memberId` (`ctx.actor.memberId`). Keep `userId` for authentication and personal resources (billing, devices, OAuth). Prefer the column name `memberId` over aliases like `authorMemberId`.
|
|
288
|
-
- Access modes: `"all"` (global), `"org"` (same organization), `"own"` (ownership), `"none"` (explicit deny). Prefer `"org"` for org owner/admin grants.
|
|
289
|
-
|
|
290
|
-
## Access Control Modes
|
|
291
|
-
|
|
292
|
-
- **`entityStep`** — references a value stored by `.loadResource()`/`.use()`;
|
|
293
|
-
the entity's `userId`, `memberId`, `organizationId`, and `teamId` are read automatically.
|
|
294
|
-
Arrays are soft-filtered; singles return `FORBIDDEN` when denied. Filtered arrays are written back to the step state.
|
|
295
|
-
- **`entities`** — inline function returning `{ userId?, memberId?, organizationId?, teamId? }`
|
|
296
|
-
when the entity is not loaded into state.
|
|
297
|
-
|
|
298
|
-
## Example Skeleton
|
|
299
|
-
|
|
300
|
-
```typescript
|
|
301
|
-
import { BasePermissionService } from "@m5kdev/backend/base/base.service";
|
|
302
|
-
import { postSchemas } from "./posts.dto";
|
|
303
|
-
import type { PostsRepository } from "./posts.repository";
|
|
304
|
-
|
|
305
|
-
export class PostsService extends BasePermissionService<
|
|
306
|
-
{ posts: PostsRepository },
|
|
307
|
-
Record<string, never>
|
|
308
|
-
> {
|
|
309
|
-
readonly list = this.procedure("list")
|
|
310
|
-
.input(postSchemas.input.list)
|
|
311
|
-
.output(postSchemas.output.list)
|
|
312
|
-
.requireAuth("organization")
|
|
313
|
-
.addContextFilter(["organization"])
|
|
314
|
-
.loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
|
|
315
|
-
.access({
|
|
316
|
-
action: "read",
|
|
317
|
-
entityStep: "posts",
|
|
318
|
-
})
|
|
319
|
-
.handle(({ state }) => state.posts);
|
|
320
|
-
|
|
321
|
-
readonly create = this.procedure("create")
|
|
322
|
-
.input(postSchemas.input.create)
|
|
323
|
-
.output(postSchemas.output.single)
|
|
324
|
-
.requireAuth("organization")
|
|
325
|
-
.access({ action: "write" })
|
|
326
|
-
.handle(async ({ input, ctx }) => {
|
|
327
|
-
return this.repository.posts.create({
|
|
328
|
-
...input,
|
|
329
|
-
userId: ctx.actor.userId,
|
|
330
|
-
memberId: ctx.actor.memberId,
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
readonly update = this.procedure("update")
|
|
335
|
-
.input(postSchemas.input.update)
|
|
336
|
-
.output(postSchemas.output.single)
|
|
337
|
-
.requireAuth("organization")
|
|
338
|
-
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
339
|
-
.access({
|
|
340
|
-
action: "write",
|
|
341
|
-
entityStep: "post",
|
|
342
|
-
})
|
|
343
|
-
.handle(async ({ input }) => {
|
|
344
|
-
return this.repository.posts.update(input);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
readonly delete = this.procedure("delete")
|
|
348
|
-
.input(postSchemas.input.delete)
|
|
349
|
-
.requireAuth("organization")
|
|
350
|
-
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
351
|
-
.access({
|
|
352
|
-
action: "delete",
|
|
353
|
-
entityStep: "post",
|
|
354
|
-
})
|
|
355
|
-
.handle(async ({ input }) => {
|
|
356
|
-
return this.repository.posts.deleteById(input.id);
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
## Best Practices
|
|
362
|
-
|
|
363
|
-
1. Keep procedures as `readonly` class properties.
|
|
364
|
-
2. `.loadResource()` before `.access({ entityStep })` — it 404s on missing rows for free.
|
|
365
|
-
3. Return `this.error("NOT_FOUND")` / `this.error("FORBIDDEN")` for expected failures; reserve 5xx codes for server faults (they report to Sentry).
|
|
366
|
-
4. Keep pure helpers (slugify, excerpt) in `<module>.utils.ts`.
|
|
367
|
-
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.
|
|
11
|
+
|
|
12
|
+
## File Location
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
apps/<app>/server/src/modules/<module>/<module>.service.ts
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Conventions
|
|
19
|
+
|
|
20
|
+
- Always extend `BasePermissionService<Repositories, Services>`.
|
|
21
|
+
- Declare repository and service dependencies as explicit named maps in generics.
|
|
22
|
+
- Instantiate service classes in `apps/*/server/src/service.ts` (composition root), not inside module files.
|
|
23
|
+
- Keep orchestration/business logic in service; repositories handle persistence only.
|
|
24
|
+
- Implement service methods as `readonly` procedures using `this.procedure("name")` builder chains.
|
|
25
|
+
- Propagate `ServerResultAsync` consistently and use `err(...)`/`ok(...)` composition.
|
|
26
|
+
- Prefer helper methods for repeated workflows (prompt prep, query assembly, enrichment).
|
|
27
|
+
- Define grants in `<module>.grants.ts` and wire them when composing the service.
|
|
28
|
+
- **Strongly prefer repository built-ins** (`queryList`, `queryFind`, `findById`, `create`, `update`, `deleteById`, …) over custom repository methods.
|
|
29
|
+
|
|
30
|
+
## Prefer Repository Built-ins
|
|
31
|
+
|
|
32
|
+
Call `BaseTableRepository` helpers from the service. Do **not** invent repository wrappers for ordinary CRUD, lists, or lookups.
|
|
33
|
+
|
|
34
|
+
- **Reads**: use `queryList` / `queryFind` with `filters` (and `addContextFilter` when org/member scoped). Usually no custom `list*` / `get*` / `findBy*` repository methods.
|
|
35
|
+
- **Creates**: `create` is permissive — stamp defaults and actor fields in the service (`memberId`, `organizationId`, `status: "draft"`, …) and pass the full row payload. No custom `create*` repo method for defaults.
|
|
36
|
+
- **Updates**: `update` accepts partial fields — only pass columns that should change. No custom `update*` repo method to omit fields or merge patches.
|
|
37
|
+
- Add a custom repository method only for joins, CTEs, multi-table writes, transactions, or predicates that cannot be expressed with filters / `TableConditionBuilder`.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// Preferred: built-in + filters / stamped defaults in the service
|
|
41
|
+
readonly listPublished = this.procedure("listPublished")
|
|
42
|
+
.input(postSchemas.input.list)
|
|
43
|
+
.output(postSchemas.output.list)
|
|
44
|
+
.requireAuth("organization")
|
|
45
|
+
.addContextFilter(["organization"])
|
|
46
|
+
.addFilters(() => [
|
|
47
|
+
{
|
|
48
|
+
columnId: "status",
|
|
49
|
+
type: "enum",
|
|
50
|
+
method: "equals",
|
|
51
|
+
value: "published",
|
|
52
|
+
},
|
|
53
|
+
])
|
|
54
|
+
.access({ action: "read" })
|
|
55
|
+
.handle(({ input }) =>
|
|
56
|
+
this.repository.posts.queryList(input, {
|
|
57
|
+
globalSearchColumns: ["title", "excerpt", "content"],
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
readonly create = this.procedure("create")
|
|
62
|
+
.input(postSchemas.input.create)
|
|
63
|
+
.output(postSchemas.output.single)
|
|
64
|
+
.requireAuth("organization")
|
|
65
|
+
.access({ action: "write" })
|
|
66
|
+
.handle(async ({ input, ctx }) => {
|
|
67
|
+
return this.repository.posts.create({
|
|
68
|
+
...input,
|
|
69
|
+
memberId: ctx.actor.memberId,
|
|
70
|
+
organizationId: ctx.session.activeOrganizationId,
|
|
71
|
+
status: "draft",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
readonly update = this.procedure("update")
|
|
76
|
+
.input(postSchemas.input.update)
|
|
77
|
+
.output(postSchemas.output.single)
|
|
78
|
+
.requireAuth("organization")
|
|
79
|
+
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
80
|
+
.access({ action: "write", entityStep: "post" })
|
|
81
|
+
.handle(async ({ input }) => {
|
|
82
|
+
// Partial update — only fields present on input are written
|
|
83
|
+
return this.repository.posts.update(input);
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Avoid Trivial Service Delegation
|
|
88
|
+
|
|
89
|
+
- Do not add a service method whose only behavior is calling another service.
|
|
90
|
+
- Call the owning service directly when no business rule, authorization, validation, orchestration, transaction boundary, or meaningful domain translation is added.
|
|
91
|
+
- Renaming the dependency method, repackaging arguments, constructing a prompt, or forwarding actor/context data alone does not justify a wrapper.
|
|
92
|
+
- A delegating method is justified only when the current service genuinely owns the use case and adds behavior or protects a boundary callers must not bypass.
|
|
93
|
+
- If removing the method and calling the dependency directly would lose nothing, do not add the method.
|
|
94
|
+
|
|
95
|
+
## Procedure Builder
|
|
96
|
+
|
|
97
|
+
Each procedure declares its schemas from the DTO, then auth, resource loading,
|
|
98
|
+
access control, and the handler:
|
|
99
|
+
|
|
100
|
+
1. `.input(schema)` -- Accepts a Zod schema; infers `TInput` so you don't need `this.procedure<Type>(...)`. No runtime validation by default; pass `true` as second arg to enable it.
|
|
101
|
+
2. `.output(schema)` -- Accepts a Zod schema; constrains the handler return type so you don't need explicit return annotations. No runtime validation by default; pass `true` as second arg to enable it.
|
|
102
|
+
3. `.requireAuth(scope?)` -- Enforces authentication. Scope can be `"user"` (default), `"organization" (recommended)`, `"team"`, or `"admin"`.
|
|
103
|
+
4. `.loadResource(name, loader)` -- Loads a value and stores it in `state`; returns `NOT_FOUND` if falsy.
|
|
104
|
+
5. `.use(name, step)` -- Runs an arbitrary step and stores the result in `state`.
|
|
105
|
+
6. `.addContextFilter(scopes?)` -- Applies context-based query filtering (requires auth).
|
|
106
|
+
7. `.addFilters(resolve)` -- Appends query filters to the current input (`({ input, ctx, state, ... }) => QueryFilter | QueryFilter[]`).
|
|
107
|
+
8. `.access(config)` -- Permission check via grants. Arrays are soft-filtered to allowed rows (empty OK); single entities return `FORBIDDEN` when denied. Filtered arrays are written back to `entityStep` state.
|
|
108
|
+
9. `.handle(handler)` -- Terminal method; runs the handler with `{ input, ctx, state, repository, service, logger }`.
|
|
109
|
+
|
|
110
|
+
### Input / Output Typing
|
|
111
|
+
|
|
112
|
+
Prefer `.input(schema)` with Zod schemas from the module's `.dto.ts`:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// Preferred: schema-driven
|
|
116
|
+
readonly create = this.procedure("create")
|
|
117
|
+
.input(postSchemas.input.create)
|
|
118
|
+
.output(postSchemas.output.single)
|
|
119
|
+
.requireAuth("organization")
|
|
120
|
+
.access({ action: "write" })
|
|
121
|
+
.handle(async ({ input, ctx }) => {
|
|
122
|
+
return this.repository.posts.create({
|
|
123
|
+
...input,
|
|
124
|
+
userId: ctx.actor.userId,
|
|
125
|
+
memberId: ctx.actor.memberId,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Acceptable for simple primitives with no matching schema
|
|
130
|
+
readonly getCount = this.procedure("getCount")
|
|
131
|
+
.requireAuth("organization")
|
|
132
|
+
.access({ action: "read" })
|
|
133
|
+
.handle(async ({ ctx }): ServerResultAsync<number> => {
|
|
134
|
+
return this.repository.posts.countByUser(ctx.actor.userId);
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
When no Zod schema exists (simple primitives, inline objects), you can still use the generic: `this.procedure<string>("name")`.
|
|
139
|
+
|
|
140
|
+
## Procedure Patterns
|
|
141
|
+
|
|
142
|
+
### Simple CRUD
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
readonly list = this.procedure("list")
|
|
146
|
+
.input(postSchemas.input.list)
|
|
147
|
+
.output(postSchemas.output.list)
|
|
148
|
+
.requireAuth("organization")
|
|
149
|
+
.addContextFilter(["organization"])
|
|
150
|
+
.loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
|
|
151
|
+
.access({
|
|
152
|
+
action: "read",
|
|
153
|
+
entityStep: "posts",
|
|
154
|
+
})
|
|
155
|
+
.handle(({ state }) => state.posts);
|
|
156
|
+
|
|
157
|
+
readonly create = this.procedure("create")
|
|
158
|
+
.input(postSchemas.input.create)
|
|
159
|
+
.output(postSchemas.output.single)
|
|
160
|
+
.requireAuth("organization")
|
|
161
|
+
.access({ action: "write" })
|
|
162
|
+
.handle(async ({ input, ctx }) => {
|
|
163
|
+
return this.repository.posts.create({
|
|
164
|
+
...input,
|
|
165
|
+
userId: ctx.actor.userId,
|
|
166
|
+
memberId: ctx.actor.memberId,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### With Resource Loading and Access Control (recommended)
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
readonly update = this.procedure("update")
|
|
175
|
+
.input(postSchemas.input.update)
|
|
176
|
+
.output(postSchemas.output.single)
|
|
177
|
+
.requireAuth("organization")
|
|
178
|
+
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
179
|
+
.access({
|
|
180
|
+
action: "write",
|
|
181
|
+
entityStep: "post",
|
|
182
|
+
})
|
|
183
|
+
.handle(async ({ input }) => {
|
|
184
|
+
return this.repository.posts.update(input);
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Use grants with `"org"` (org admin/owner) or `"own"` (member) so `findById` cannot update cross-org rows. Do not use `"all"` for organization-scoped roles.
|
|
189
|
+
|
|
190
|
+
### Org-scoped list with access soft-filter
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
readonly list = this.procedure("list")
|
|
194
|
+
.input(postSchemas.input.list)
|
|
195
|
+
.output(postSchemas.output.list)
|
|
196
|
+
.requireAuth("organization")
|
|
197
|
+
.addContextFilter(["organization"])
|
|
198
|
+
.loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
|
|
199
|
+
.access({
|
|
200
|
+
action: "read",
|
|
201
|
+
entityStep: "posts",
|
|
202
|
+
})
|
|
203
|
+
.handle(({ state }) => state.posts);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- Default list scoping: `addContextFilter(["organization"])`.
|
|
207
|
+
- When grants are member-own only, prefer `addContextFilter(["member", "organization"])` so the query is already narrowed.
|
|
208
|
+
- `.access` on an **array** soft-filters to allowed rows (may be `[]`); it does not 403 the whole list.
|
|
209
|
+
- Filtered results are written back to `state[entityStep]` (here `state.posts`).
|
|
210
|
+
|
|
211
|
+
### With Context Filter (Query-Based Lists)
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
readonly list = this.procedure("list")
|
|
215
|
+
.output(postSchemas.output.list)
|
|
216
|
+
.requireAuth("organization")
|
|
217
|
+
.addContextFilter(["member", "organization"])
|
|
218
|
+
.access({ action: "read" })
|
|
219
|
+
.handle(({ input }) => {
|
|
220
|
+
return this.repository.posts.queryList(input, {
|
|
221
|
+
globalSearchColumns: ["title", "excerpt", "content"],
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Create with business logic
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
readonly create = this.procedure("create")
|
|
230
|
+
.requireAuth("organization")
|
|
231
|
+
.input(postSchemas.input.create)
|
|
232
|
+
.output(postSchemas.output.single)
|
|
233
|
+
.access({ action: "write" })
|
|
234
|
+
.handle(async ({ input, ctx }) => {
|
|
235
|
+
const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
|
|
236
|
+
candidate: slugify(input.slug ?? input.title),
|
|
237
|
+
});
|
|
238
|
+
if (uniqueSlug.isErr()) return err(uniqueSlug.error);
|
|
239
|
+
|
|
240
|
+
return this.repository.posts.create({
|
|
241
|
+
// Org-scoped assets: stamp memberId (+ organizationId). Personal resources: stamp userId.
|
|
242
|
+
memberId: ctx.actor.memberId,
|
|
243
|
+
organizationId: ctx.actor.organizationId,
|
|
244
|
+
title: input.title.trim(),
|
|
245
|
+
slug: uniqueSlug.value,
|
|
246
|
+
content: input.content.trim(),
|
|
247
|
+
status: "draft",
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Soft delete
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
readonly softDelete = this.procedure("softDelete")
|
|
256
|
+
.input(postSchemas.input.delete)
|
|
257
|
+
.output(postSchemas.output.uuid)
|
|
258
|
+
.requireAuth("organization")
|
|
259
|
+
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
260
|
+
.access({ action: "delete", entityStep: "post" })
|
|
261
|
+
.handle(async ({ input }) => {
|
|
262
|
+
const updated = await this.repository.posts.update({
|
|
263
|
+
id: input.id,
|
|
264
|
+
deletedAt: new Date(),
|
|
265
|
+
});
|
|
266
|
+
if (updated.isErr()) return err(updated.error);
|
|
267
|
+
return ok({ id: updated.value.id });
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Public (No Auth)
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
readonly validateCode = this.procedure("validateCode")
|
|
275
|
+
.input(postSchemas.input.validate)
|
|
276
|
+
.output(postSchemas.output.status)
|
|
277
|
+
.handle(async ({ input }) => {
|
|
278
|
+
return this.repository.posts.validateCode(input);
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Permission and Grants
|
|
283
|
+
|
|
284
|
+
- Keep all guarded action names aligned with grants (`read`, `write`, `delete`, `publish`, etc.).
|
|
285
|
+
- Do not use undeclared action aliases (e.g., guard `"update"` when grants only define `"write"`).
|
|
286
|
+
- Prefer `.access({ action, entityStep? })` over ad-hoc ownership checks in `.use`.
|
|
287
|
+
- For org-scoped assets, stamp and authorize with `memberId` (`ctx.actor.memberId`). Keep `userId` for authentication and personal resources (billing, devices, OAuth). Prefer the column name `memberId` over aliases like `authorMemberId`.
|
|
288
|
+
- Access modes: `"all"` (global), `"org"` (same organization), `"own"` (ownership), `"none"` (explicit deny). Prefer `"org"` for org owner/admin grants.
|
|
289
|
+
|
|
290
|
+
## Access Control Modes
|
|
291
|
+
|
|
292
|
+
- **`entityStep`** — references a value stored by `.loadResource()`/`.use()`;
|
|
293
|
+
the entity's `userId`, `memberId`, `organizationId`, and `teamId` are read automatically.
|
|
294
|
+
Arrays are soft-filtered; singles return `FORBIDDEN` when denied. Filtered arrays are written back to the step state.
|
|
295
|
+
- **`entities`** — inline function returning `{ userId?, memberId?, organizationId?, teamId? }`
|
|
296
|
+
when the entity is not loaded into state.
|
|
297
|
+
|
|
298
|
+
## Example Skeleton
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import { BasePermissionService } from "@m5kdev/backend/base/base.service";
|
|
302
|
+
import { postSchemas } from "./posts.dto";
|
|
303
|
+
import type { PostsRepository } from "./posts.repository";
|
|
304
|
+
|
|
305
|
+
export class PostsService extends BasePermissionService<
|
|
306
|
+
{ posts: PostsRepository },
|
|
307
|
+
Record<string, never>
|
|
308
|
+
> {
|
|
309
|
+
readonly list = this.procedure("list")
|
|
310
|
+
.input(postSchemas.input.list)
|
|
311
|
+
.output(postSchemas.output.list)
|
|
312
|
+
.requireAuth("organization")
|
|
313
|
+
.addContextFilter(["organization"])
|
|
314
|
+
.loadResource("posts", ({ input }) => this.repository.posts.queryList(input))
|
|
315
|
+
.access({
|
|
316
|
+
action: "read",
|
|
317
|
+
entityStep: "posts",
|
|
318
|
+
})
|
|
319
|
+
.handle(({ state }) => state.posts);
|
|
320
|
+
|
|
321
|
+
readonly create = this.procedure("create")
|
|
322
|
+
.input(postSchemas.input.create)
|
|
323
|
+
.output(postSchemas.output.single)
|
|
324
|
+
.requireAuth("organization")
|
|
325
|
+
.access({ action: "write" })
|
|
326
|
+
.handle(async ({ input, ctx }) => {
|
|
327
|
+
return this.repository.posts.create({
|
|
328
|
+
...input,
|
|
329
|
+
userId: ctx.actor.userId,
|
|
330
|
+
memberId: ctx.actor.memberId,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
readonly update = this.procedure("update")
|
|
335
|
+
.input(postSchemas.input.update)
|
|
336
|
+
.output(postSchemas.output.single)
|
|
337
|
+
.requireAuth("organization")
|
|
338
|
+
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
339
|
+
.access({
|
|
340
|
+
action: "write",
|
|
341
|
+
entityStep: "post",
|
|
342
|
+
})
|
|
343
|
+
.handle(async ({ input }) => {
|
|
344
|
+
return this.repository.posts.update(input);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
readonly delete = this.procedure("delete")
|
|
348
|
+
.input(postSchemas.input.delete)
|
|
349
|
+
.requireAuth("organization")
|
|
350
|
+
.loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
|
|
351
|
+
.access({
|
|
352
|
+
action: "delete",
|
|
353
|
+
entityStep: "post",
|
|
354
|
+
})
|
|
355
|
+
.handle(async ({ input }) => {
|
|
356
|
+
return this.repository.posts.deleteById(input.id);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Best Practices
|
|
362
|
+
|
|
363
|
+
1. Keep procedures as `readonly` class properties.
|
|
364
|
+
2. `.loadResource()` before `.access({ entityStep })` — it 404s on missing rows for free.
|
|
365
|
+
3. Return `this.error("NOT_FOUND")` / `this.error("FORBIDDEN")` for expected failures; reserve 5xx codes for server faults (they report to Sentry).
|
|
366
|
+
4. Keep pure helpers (slugify, excerpt) in `<module>.utils.ts`.
|
|
367
|
+
5. Propagate `err(...)` from repository results rather than throwing.
|