spiderly 19.8.3 → 19.8.5

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 (35) hide show
  1. package/agent/docs/angular-customization/SKILL.md +389 -0
  2. package/agent/docs/angular-customization/references/controls.generated.md +23 -0
  3. package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
  4. package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
  5. package/agent/docs/angular-customization/references/validators.generated.md +13 -0
  6. package/agent/docs/authorization/SKILL.md +385 -0
  7. package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
  8. package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
  9. package/agent/docs/backend-hooks/SKILL.md +231 -0
  10. package/agent/docs/backend-localization/SKILL.md +170 -0
  11. package/agent/docs/backend-testing/SKILL.md +65 -0
  12. package/agent/docs/custom-endpoints/SKILL.md +409 -0
  13. package/agent/docs/e2e-testing/SKILL.md +139 -0
  14. package/agent/docs/entity-design/SKILL.md +346 -0
  15. package/agent/docs/entity-design/references/attributes.generated.md +53 -0
  16. package/agent/docs/file-storage/SKILL.md +262 -0
  17. package/agent/docs/filtering-patterns/SKILL.md +127 -0
  18. package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
  19. package/agent/docs/frontend-localization/SKILL.md +120 -0
  20. package/agent/docs/mapper-customization/SKILL.md +105 -0
  21. package/agent/manifest.json +34 -0
  22. package/agent/skills/add-entity/SKILL.md +158 -0
  23. package/agent/skills/deployment/SKILL.md +551 -0
  24. package/agent/skills/ef-migrations/SKILL.md +49 -0
  25. package/agent/skills/report-gap/SKILL.md +110 -0
  26. package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
  27. package/agent/skills/spiderly-upgrade/SKILL.md +165 -0
  28. package/agent/skills/verify-ui/SKILL.md +148 -0
  29. package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
  30. package/fesm2022/spiderly.mjs +14 -8
  31. package/fesm2022/spiderly.mjs.map +1 -1
  32. package/lib/components/auth/login/login.component.d.ts +1 -1
  33. package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
  34. package/lib/errors/api-error-codes.d.ts +1 -0
  35. package/package.json +1 -1
@@ -0,0 +1,385 @@
1
+ ---
2
+ name: authorization
3
+ description: Set up permission-based authorization in Spiderly. Use when implementing user/role/permission entities, seeding permissions, using DoNotAuthorize or AuthGuard attributes, checking permissions in custom code, configuring frontend auth guards, or setting up Google OAuth.
4
+ ---
5
+
6
+ # Authorization
7
+
8
+ ## Entity Interfaces
9
+
10
+ Implement these on your User, Role, and Permission entities:
11
+
12
+ ### ISecurityPrincipal
13
+
14
+ The authorization root — anything that can hold roles and be permission-checked (a human `User`, a
15
+ machine/service account, an AI agent). Authorization resolves permissions against this contract, not a
16
+ concrete user type, so an app may have **one** principal kind (just `User`) or **many**.
17
+
18
+ ```csharp
19
+ public interface ISecurityPrincipal : IBusinessObject<long>
20
+ {
21
+ bool? IsDisabled { get; set; }
22
+ IReadOnlyCollection<IRole> Roles { get; }
23
+ }
24
+ ```
25
+
26
+ ### IUser
27
+
28
+ A human principal that authenticates by email. Inherits identity, disabled-state, and roles from
29
+ `ISecurityPrincipal`; adds only `Email`.
30
+
31
+ ```csharp
32
+ public interface IUser : ISecurityPrincipal
33
+ {
34
+ string Email { get; set; }
35
+ }
36
+ ```
37
+
38
+ ### IRole
39
+
40
+ ```csharp
41
+ public interface IRole : IBusinessObject<int>
42
+ {
43
+ string Name { get; set; }
44
+ IReadOnlyCollection<IUser> Users { get; }
45
+ IReadOnlyCollection<IPermission> Permissions { get; }
46
+ }
47
+ ```
48
+
49
+ ### IPermission
50
+
51
+ ```csharp
52
+ public interface IPermission : IReadonlyObject<int>
53
+ {
54
+ string Name { get; set; }
55
+ string Code { get; set; }
56
+ IReadOnlyCollection<IRole> Roles { get; }
57
+ }
58
+ ```
59
+
60
+ ### Real-World Entity Example
61
+
62
+ ```csharp
63
+ [Index(nameof(Email), IsUnique = true)]
64
+ public class User : BusinessObject<long>, IUser
65
+ {
66
+ [Required]
67
+ [StringLength(70, MinimumLength = 5)]
68
+ [Email]
69
+ public string Email { get; set; }
70
+
71
+ public string FirstName { get; set; }
72
+ public string LastName { get; set; }
73
+
74
+ public bool? HasLoggedInWithGoogleAsExternalProvider { get; set; }
75
+ public bool? IsDisabled { get; set; }
76
+
77
+ public virtual List<Role> Roles { get; } = new();
78
+ IReadOnlyCollection<IRole> ISecurityPrincipal.Roles => Roles; // Roles moved to the principal base
79
+ }
80
+
81
+ public class Role : BusinessObject<int>, IRole
82
+ {
83
+ [Required]
84
+ [StringLength(255, MinimumLength = 1)]
85
+ public string Name { get; set; }
86
+
87
+ [UIControlType(nameof(UIControlTypeCodes.MultiAutocomplete))]
88
+ public virtual List<User> Users { get; } = new();
89
+ IReadOnlyCollection<IUser> IRole.Users => Users;
90
+
91
+ [UIControlType(nameof(UIControlTypeCodes.MultiSelect))]
92
+ public virtual List<Permission> Permissions { get; } = new();
93
+ IReadOnlyCollection<IPermission> IRole.Permissions => Permissions;
94
+ }
95
+
96
+ [UIDoNotGenerate]
97
+ [Index(nameof(Code), IsUnique = true)]
98
+ public class Permission : ReadonlyObject<int>, IPermission
99
+ {
100
+ [Required]
101
+ [StringLength(100, MinimumLength = 1)]
102
+ public string Name { get; set; }
103
+
104
+ [Required]
105
+ [StringLength(100, MinimumLength = 1)]
106
+ public string Code { get; set; }
107
+
108
+ public virtual List<Role> Roles { get; } = new();
109
+ IReadOnlyCollection<IRole> IPermission.Roles => Roles;
110
+ }
111
+ ```
112
+
113
+ ## Permission Code Convention
114
+
115
+ Auto-generated per entity (via `PermissionCodesGenerator`):
116
+
117
+ | Code | Purpose |
118
+ | ---------------- | ----------------- |
119
+ | `Read{Entity}` | View list/details |
120
+ | `Update{Entity}` | Modify existing |
121
+ | `Insert{Entity}` | Create new |
122
+ | `Delete{Entity}` | Remove |
123
+
124
+ Generated as a partial class:
125
+
126
+ ```csharp
127
+ public static partial class PermissionCodes
128
+ {
129
+ public static string ReadProduct { get; } = "ReadProduct";
130
+ public static string UpdateProduct { get; } = "UpdateProduct";
131
+ public static string InsertProduct { get; } = "InsertProduct";
132
+ public static string DeleteProduct { get; } = "DeleteProduct";
133
+ // ... one set per entity
134
+ }
135
+ ```
136
+
137
+ Extend with custom codes:
138
+
139
+ ```csharp
140
+ public static partial class PermissionCodes
141
+ {
142
+ public static string ExportReports { get; } = "ExportReports";
143
+ }
144
+ ```
145
+
146
+ ## Seeding Permissions
147
+
148
+ In `ApplicationDbContext.SeedData()`:
149
+
150
+ ```csharp
151
+ private static void SeedData(ModelBuilder modelBuilder)
152
+ {
153
+ DateTime seedDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
154
+
155
+ Permission[] permissions =
156
+ [
157
+ new Permission { Id = 1, Name = "View users", Code = "ReadUser" },
158
+ new Permission { Id = 2, Name = "Edit users", Code = "UpdateUser" },
159
+ new Permission { Id = 3, Name = "Add users", Code = "InsertUser" },
160
+ new Permission { Id = 4, Name = "Delete users", Code = "DeleteUser" },
161
+ new Permission { Id = 5, Name = "View products", Code = "ReadProduct" },
162
+ // ... more permissions with sequential IDs
163
+ ];
164
+
165
+ modelBuilder.Entity<Permission>().HasData(permissions);
166
+
167
+ modelBuilder.Entity<Role>().HasData(new Role
168
+ {
169
+ Id = 1,
170
+ Name = "Admin",
171
+ CreatedAt = seedDate,
172
+ ModifiedAt = seedDate,
173
+ });
174
+
175
+ modelBuilder.Entity<Role>()
176
+ .HasMany(r => r.Permissions)
177
+ .WithMany(p => p.Roles)
178
+ .UsingEntity(j => j.HasData(
179
+ permissions.Select((p, i) => new { RoleId = 1, PermissionId = p.Id }).ToArray()
180
+ ));
181
+ }
182
+ ```
183
+
184
+ After adding new permissions: `spiderly add-migration AddNewPermissions` → `spiderly update-database`.
185
+
186
+ ## First-User Bootstrap
187
+
188
+ Permissions and an "Admin" role are seeded via `HasData()` in the generated `ApplicationDbContext` (applied through EF migrations). On first login, `SecurityService.OnAfterLogin` auto-grants Admin to the user — no manual bootstrap needed on a fresh deploy.
189
+
190
+ ## Attributes
191
+
192
+ ### `[DoNotAuthorize]`
193
+
194
+ Skip all permission checks for an entity:
195
+
196
+ ```csharp
197
+ [DoNotAuthorize]
198
+ public class PaymentMethod : ReadonlyObject<byte> { ... }
199
+ ```
200
+
201
+ Use for public lookup tables. Generated CRUD endpoints won't require login.
202
+
203
+ ### `[AuthGuard]`
204
+
205
+ Require valid JWT on a controller action:
206
+
207
+ ```csharp
208
+ [HttpGet]
209
+ [AuthGuard]
210
+ public async Task<UserBaseDTO> GetProfile() { ... }
211
+ ```
212
+
213
+ Validates JWT from `Authorization: Bearer {token}` header. Returns 401 if invalid. Applied automatically on all generated CRUD endpoints (unless entity has `[DoNotAuthorize]`).
214
+
215
+ ## Generated Authorization Service
216
+
217
+ `AuthorizationServicesGenerator` creates per-entity authorization methods:
218
+
219
+ ```csharp
220
+ // Generated — override in your AuthorizationService to customize.
221
+ // The call is principal-kind-agnostic: it authorizes the current principal (whatever kind) by
222
+ // resolving it through the principal registry — no compile-time user type.
223
+ public virtual async Task AuthorizeProductReadAndThrow(long? productIdToRead)
224
+ {
225
+ await AuthorizeAndThrowAsync(PermissionCodes.ReadProduct);
226
+ }
227
+
228
+ public virtual async Task AuthorizeProductUpdateAndThrow(ProductDTO dto)
229
+ {
230
+ await AuthorizeAndThrowAsync(PermissionCodes.UpdateProduct);
231
+ }
232
+
233
+ public virtual async Task AuthorizeProductInsertAndThrow(ProductDTO dto)
234
+ {
235
+ await AuthorizeAndThrowAsync(PermissionCodes.InsertProduct);
236
+ }
237
+
238
+ public virtual async Task AuthorizeProductDeleteAndThrow(long id)
239
+ {
240
+ await AuthorizeAndThrowAsync(PermissionCodes.DeleteProduct);
241
+ }
242
+ ```
243
+
244
+ ## Registering Principal Kinds
245
+
246
+ Register each principal kind in `AddAppServices` so authorization can resolve the current principal.
247
+ A single-principal app registers just `User`; the kind-dispatched authorization then resolves it
248
+ without a `principal_kind` claim (it is the sole, default kind):
249
+
250
+ ```csharp
251
+ services.AddSpiderlyPrincipal<User>("User");
252
+ // Add a line per additional principal kind, e.g.:
253
+ // services.AddSpiderlyPrincipal<ServiceAccount>("ServiceAccount");
254
+ ```
255
+
256
+ Each kind is any entity implementing `ISecurityPrincipal` with its own `Roles` into the shared
257
+ Role/Permission catalog. (`spiderly init` scaffolds the `User` registration for you.)
258
+
259
+ ## Checking Permissions in Custom Code
260
+
261
+ ```csharp
262
+ // Preferred — authorizes the current principal whatever its kind:
263
+ await _authorizationService.AuthorizeAndThrowAsync(PermissionCodes.ExportReports);
264
+
265
+ // Check without throwing:
266
+ bool canExport = await _authorizationService.IsAuthorizedAsync(PermissionCodes.ExportReports);
267
+
268
+ // The generic overload still exists for an explicit single-type check (e.g. a User-only admin path):
269
+ await _authorizationService.AuthorizeAndThrowAsync<User>(PermissionCodes.ExportReports);
270
+ ```
271
+
272
+ ## Authentication Flow
273
+
274
+ Spiderly uses **email-based login** (no passwords):
275
+
276
+ ```
277
+ 1. Client sends email → SendLoginVerificationEmail
278
+ 2. Server sends 6-digit code via email (or shows in dev mode)
279
+ 3. Client sends code → Login
280
+ 4. Server returns access token (JWT, 20 min) + refresh token (24h)
281
+ 5. Auto-refresh 5 seconds before expiration
282
+ ```
283
+
284
+ ### SecurityServiceBase Hooks
285
+
286
+ ```csharp
287
+ public class SecurityService : SecurityServiceBase<User>
288
+ {
289
+ public override async Task OnAfterLogin(AuthResultDTO authResultDTO)
290
+ {
291
+ // Custom post-login logic (analytics, logging, etc.)
292
+ }
293
+ }
294
+ ```
295
+
296
+ ### SecurityBaseController Endpoints
297
+
298
+ The full auth API surface — every endpoint, its HTTP method, whether it needs a valid access token, and what it does — is generated from `SecurityBaseController`: see [references/security-endpoints.generated.md](references/security-endpoints.generated.md).
299
+
300
+ ## API error codes
301
+
302
+ Failed requests return an `ApiErrorDTO` whose machine-readable `errorCode` clients switch on (the Angular interceptor, storefront middleware, external API consumers). The full list — names, wire values, and when each is returned — is generated from the `ApiErrorCodes` contract: see [references/api-error-codes.generated.md](references/api-error-codes.generated.md).
303
+
304
+ ## Google OAuth Setup
305
+
306
+ 1. Get a Google Client ID from Google Developer Console
307
+ 2. Set in `Backend/appsettings.json`:
308
+ ```json
309
+ { "AppSettings": { "Spiderly.Shared": { "GoogleClientId": "..." } } }
310
+ ```
311
+ 3. Set in `Frontend/src/environments/environment.ts`:
312
+ ```typescript
313
+ GoogleClientId: "...";
314
+ ```
315
+ 4. Enable in config service:
316
+ ```typescript
317
+ override showGoogleAuth = true;
318
+ ```
319
+
320
+ Flow: Google returns JWT → `LoginExternal` validates → auto-creates user if new → returns tokens.
321
+
322
+ ## Frontend Auth
323
+
324
+ ### AuthServiceBase
325
+
326
+ Key observables:
327
+
328
+ ```typescript
329
+ user$: Observable<UserBase | null>; // Current user
330
+ currentUserPermissionCodes$: Observable<string[]>; // Permission codes
331
+ ```
332
+
333
+ Key methods:
334
+
335
+ ```typescript
336
+ login(body: VerificationTokenRequest): Observable<Promise<AuthResult>>
337
+ loginExternal(body: ExternalProvider): Observable<Promise<AuthResult>>
338
+ logout()
339
+ refreshToken(): Observable<AuthResult>
340
+ ```
341
+
342
+ Overridable hooks:
343
+
344
+ ```typescript
345
+ onAfterLoginExternal = () => { ... }
346
+ onAfterLogout = () => { ... }
347
+ onAfterRefreshToken = () => { ... }
348
+ ```
349
+
350
+ ### Route Guards
351
+
352
+ ```typescript
353
+ // Protect authenticated routes
354
+ { path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] }
355
+
356
+ // Protect login page from logged-in users
357
+ { path: 'login', component: LoginComponent, canActivate: [NotAuthGuard] }
358
+ ```
359
+
360
+ ### Permission-Based Menu Visibility
361
+
362
+ ```typescript
363
+ menu: SpiderlyMenuItem[] = [
364
+ {
365
+ label: 'Users',
366
+ routerLink: ['/user-list'],
367
+ hasPermission: (codes) => codes.includes('ReadUser'),
368
+ },
369
+ ];
370
+ ```
371
+
372
+ ### Multi-Tab Sync
373
+
374
+ Login/logout events sync across browser tabs via `localStorage` events. `getBrowserId()` generates a UUID per browser — server limits to 5 concurrent sessions per user.
375
+
376
+ ## Settings Reference
377
+
378
+ ```csharp
379
+ AccessTokenExpiration = 20 // minutes
380
+ RefreshTokenExpiration = 1440 // minutes (24h)
381
+ VerificationTokenExpiration = 5 // minutes
382
+ AllowedBrowsersForTheSingleUser = 5
383
+ OnlyAdminCanAddUsers = false // true = block self-registration
384
+ AllowTheUseOfAppWithDifferentIpAddresses = true
385
+ ```
@@ -0,0 +1,17 @@
1
+ <!-- GENERATED FROM framework-metadata.json — DO NOT EDIT.
2
+ Regenerate: `dotnet run --project Spiderly.MetadataExporter -- --out framework-metadata.json && node tools/extract-ts-metadata.mjs && node tools/gen-skill-docs.mjs` -->
3
+
4
+ # API error codes
5
+
6
+ Machine-readable error codes returned in ErrorCode. Treat as a public contract — clients (Angular interceptor, storefront middleware, external API consumers) switch on these values.
7
+
8
+ | Name | Value | Description |
9
+ | --- | --- | --- |
10
+ | `ConcurrencyConflict` | `concurrency_conflict` | An optimistic-concurrency check failed — the row was modified by someone else after it was loaded. The client should reload the latest data and retry. |
11
+ | `EmailNotVerified` | `email_not_verified` | Login or auto-provisioning was blocked because the account's email address is not verified (e.g. an external provider returned an unverified email). |
12
+ | `ExternalEmailMissing` | `external_email_missing` | An external (OAuth/OIDC) login was validated but the provider returned no email address (e.g. the user declined the email permission, or a phone-only Facebook account). Auto-provisioning needs an email to key the account on, so login is rejected with this code and the client should route the user to another sign-in method. Distinct from EmailNotVerified, which means an email was returned but not verified. |
13
+ | `ExternalProviderNotConfigured` | `external_provider_not_configured` | An external (OAuth) login was attempted for a provider that is not configured on the server, or that provider's token exchange failed. |
14
+ | `ForeignKeyViolation` | `foreign_key_violation` | A database foreign-key constraint was violated — e.g. referencing a row that does not exist, or deleting a row that is still referenced by dependent rows. |
15
+ | `InvalidToken` | `invalid_token` | The JWT bearer token is missing, malformed, or expired. Returned with HTTP 401 (also surfaced in the WWW-Authenticate header); the client should refresh the token or re-authenticate. |
16
+ | `UniqueViolation` | `unique_violation` | A database unique constraint (or unique index) was violated — e.g. saving a duplicate value for a column that must be unique. |
17
+ | `ValidationFailed` | `validation_failed` | One or more request fields failed server-side validation. Returned with HTTP 400; the per-field messages are carried in ApiErrorDTO.FieldErrors. |
@@ -0,0 +1,24 @@
1
+ <!-- GENERATED FROM framework-metadata.json — DO NOT EDIT.
2
+ Regenerate: `dotnet run --project Spiderly.MetadataExporter -- --out framework-metadata.json && node tools/extract-ts-metadata.mjs && node tools/gen-skill-docs.mjs` -->
3
+
4
+ # SecurityBaseController endpoints
5
+
6
+ A base controller providing core security functionalities such as authentication, user management, and role-based access control. It leverages various services for handling user authentication (login, registration, logout, token refresh). This controller is designed to be extended for specific user types.
7
+
8
+ | Endpoint | Method | Auth | Description |
9
+ | --- | --- | --- | --- |
10
+ | `ExternalLoginCallback` | GET | No | Server-side external-login step 2: the provider redirects here with the code. Exchanges it for the id token (server-side), validates + links the user, issues the session as HttpOnly cookies, and redirects back to the originating app (returnUrl). This is a top-level browser navigation, so failures must redirect back to the app (never render a JSON error onto the API origin). The app surfaces a friendly message via the externalAuthError hint: expired (the state cookie lapsed — the user lingered on the provider's picker) or failed (invalid state/nonce, failed code exchange, or denied consent). |
11
+ | `ExternalLoginChallenge` | GET | No | Server-side external-login step 1: redirects the browser to the provider's authorize endpoint. The state/nonce/PKCE-verifier are stored in a short-lived, Data-Protection-signed HttpOnly cookie. |
12
+ | `GetCurrentUserBase` | GET | Yes | Returns the authenticated user's base profile (id, email, core fields). Requires a valid access token. |
13
+ | `GetCurrentUserPermissionCodes` | GET | Yes | Returns the permission codes granted to the authenticated user via their roles. Requires a valid access token. |
14
+ | `GetExternalLoginNonce` | GET | No | Issues a one-time nonce for the client-side (GIS / id-token) external-login flow: returns the raw nonce for the SPA to pass to the provider's sign-in call (so it is echoed into the id token), and stores a signed copy in a short-lived HttpOnly cookie that LoginExternal / LoginExternalWithCookies verify the returned id token against. Anonymous. SameSite=None so the cookie rides the cross-site login POST. |
15
+ | `GetExternalProviders` | GET | No | Public list of enabled external providers (code + OIDC authority + client id + button display), so the frontend can render sign-in buttons and run the client OIDC flow. Anonymous — the values are public by OIDC design. |
16
+ | `Login` | POST | No | Passwordless login step 2: verifies the emailed code and, on success, returns the access + refresh tokens in the response body. Anonymous. |
17
+ | `LoginExternal` | POST | No | Client-side external (OIDC) login: validates the provider id token against the single-use nonce cookie and returns the access + refresh tokens in the response body. Anonymous. |
18
+ | `LoginExternalWithCookies` | POST | No | Like LoginExternal, but issues the session as HttpOnly cookies instead of returning the tokens in the response body. Anonymous. |
19
+ | `LoginWithCookies` | POST | No | Like Login, but issues the session as HttpOnly cookies instead of returning the tokens in the response body. Anonymous. |
20
+ | `Logout` | GET | Yes | Invalidates the current user's refresh token for the given browser session. Requires a valid access token. |
21
+ | `LogoutWithCookies` | GET | Yes | Like Logout, and additionally clears the auth HttpOnly cookies. Requires a valid access token. |
22
+ | `RefreshTokenWithCookies` | POST | No | Refreshes the access token using the refresh token stored in an HttpOnly cookie. POST (not GET) because it mutates server state — it rotates the single-use refresh token. A safe/idempotent GET was cacheable, so browsers replayed a stale "logged-in" body on back/forward navigations (phantom dashboard after logout); POST is never cached, and the controller-level no-store is belt-and-braces. |
23
+ | `RefreshTokenWithHeaders` | POST | No | Refreshes the access token using the refresh token supplied in the request body, returning a new access + refresh token pair. Anonymous — the refresh token is itself the credential. |
24
+ | `SendLoginVerificationEmail` | POST | No | Passwordless login step 1: sends a short-lived numeric verification code to the user's email. Anonymous. |
@@ -0,0 +1,231 @@
1
+ ---
2
+ name: backend-hooks
3
+ description: Override Spiderly lifecycle hooks to customize generated CRUD behavior. Use when overriding lifecycle hooks, customizing generated CRUD logic, adding business logic to save/delete/get operations, handling MARS exceptions or transaction issues, or throwing business/security-violation exceptions.
4
+ ---
5
+
6
+ # Backend Hooks
7
+
8
+ ## Inheritance Chain
9
+
10
+ ```
11
+ ServiceBase (Spiderly.Shared — concrete base class)
12
+
13
+ {Entity}ServiceGenerated (generated — per-entity virtual hooks)
14
+
15
+ {Entity}Service (your code — override hooks here)
16
+ ```
17
+
18
+ Each entity gets its own generated service class. All generated methods are `public virtual` or `protected virtual`. Override them by creating an `{Entity}Service` class that inherits from `{Entity}ServiceGenerated`. DI registration is fully auto-generated — the source generator detects your override class and registers it automatically.
19
+
20
+ The generated service receives `EntityServiceDependencies` (bundles `IApplicationDbContext`, `ExcelService`, `AuthorizationService`, `IFileManager`, `IStringLocalizer`, `IServiceProvider`). Access them via `_deps`.
21
+
22
+ ## Hook Signatures by Phase
23
+
24
+ ### Save Flow (execution order)
25
+
26
+ ```
27
+ 1. SaveBody validation (SaveBodyDTOValidationRules — usually empty)
28
+ 2. OnBeforeSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO)
29
+ 3. DTO validation ({Entity}DTOValidationRules — NotEmpty, Length, etc.)
30
+ 4. OnBefore{Entity}IsMapped(DTO)
31
+ 5. OnBefore{Entity}Insert(entity, DTO) — or — OnBefore{Entity}Update(entity, DTO)
32
+ 6. SaveChangesAsync
33
+ 7. Update M2M + ordered O2M collections
34
+ 8. OnAfterSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO, MainUIFormDTO)
35
+ ```
36
+
37
+ Step 2 runs **before** step 3 — this means `OnBeforeSave` can set server-generated fields (e.g., `[UIDoNotGenerate]` + `[Required]` properties like hashes or computed values) before DTO validation runs.
38
+
39
+ On the **update path**, the entity load before step 5b goes through `GetInstanceAsync(id, dto.Version)` — it throws a localized `ConcurrencyException` (HTTP 409) when the client's `Version` is stale, and the whole flow runs in a transaction. Generated saves are therefore protected by optimistic concurrency out of the box; don't add manual race-condition guards to hooks for plain concurrent edits. Mechanism and limits: `entity-design` skill, *Base Classes* (`BusinessObject` `Version`).
40
+
41
+ **Signatures:**
42
+
43
+ ```csharp
44
+ // Step 2 — modify DTO before DTO validation
45
+ protected virtual async Task OnBeforeSave{Entity}AndReturnMainUIFormDTO(
46
+ {Entity}SaveBodyDTO saveBodyDTO) { }
47
+
48
+ // Step 4 — just before DTO→Entity mapping
49
+ protected virtual async Task OnBefore{Entity}IsMapped(
50
+ {Entity}DTO dto) { }
51
+
52
+ // Step 5a — after mapping, before insert
53
+ protected virtual async Task OnBefore{Entity}Insert(
54
+ {Entity} entity, {Entity}DTO dto) { }
55
+
56
+ // Step 5b — after loading from DB, before update
57
+ protected virtual async Task OnBefore{Entity}Update(
58
+ {Entity} entity, {Entity}DTO dto) { }
59
+
60
+ // Step 8 — after everything is saved
61
+ protected virtual async Task OnAfterSave{Entity}AndReturnMainUIFormDTO(
62
+ {Entity}SaveBodyDTO saveBodyDTO,
63
+ {Entity}MainUIFormDTO mainUIFormDTO) { }
64
+ ```
65
+
66
+ ### Delete Hooks
67
+
68
+ ```csharp
69
+ // Default forwards to OnBefore{Entity}ListDelete with a single-element list.
70
+ public virtual Task OnBefore{Entity}Delete({IdType} id) =>
71
+ OnBefore{Entity}ListDelete(id.StructToList());
72
+
73
+ public virtual async Task OnBefore{Entity}ListDelete(List<{IdType}> ids) { }
74
+ ```
75
+
76
+ **When single and batch deletes need the same logic, override only the list hook** — the single-id path forwards to it automatically. Override the single hook only when the per-id behaviour genuinely diverges from the batch case.
77
+
78
+ ### Get Hooks
79
+
80
+ ```csharp
81
+ // After MainUIFormDTO is constructed (enrich with computed fields)
82
+ protected virtual async Task OnAfterGet{Entity}MainUIFormDTO(
83
+ {Entity}MainUIFormDTO mainUIFormDTO) { }
84
+ ```
85
+
86
+ ### Paginated List (override the whole method)
87
+
88
+ Override in your `{Entity}Service`:
89
+
90
+ ```csharp
91
+ public override async Task<PaginatedResultDTO<{Entity}DTO>> GetPaginated{Entity}List(
92
+ FilterDTO filterDTO, IQueryable<{Entity}> query, bool authorize)
93
+ {
94
+ query = query.Where(x => x.IsActive);
95
+ return await base.GetPaginated{Entity}List(filterDTO, query, authorize);
96
+ }
97
+ ```
98
+
99
+ ### Blob/File Upload Hooks
100
+
101
+ ```csharp
102
+ // Before upload authorization
103
+ public virtual async Task OnBefore{Property}BlobFor{Entity}UploadIsAuthorized(
104
+ IFormFile file, {IdType} id) { }
105
+
106
+ // Before upload to storage (transform bytes, validate)
107
+ public virtual async Task<byte[]> OnBefore{Property}BlobFor{Entity}IsUploaded(
108
+ Stream stream, IFormFile file, {IdType} id) { }
109
+
110
+ // Image-specific hooks (called by OnBefore*IsUploaded for image/* content types)
111
+ public virtual async Task ValidateImageFor{Property}Of{Entity}(
112
+ Stream stream, IFormFile file, {IdType} id) { }
113
+ public virtual async Task<byte[]> OptimizeImageFor{Property}Of{Entity}(
114
+ Stream stream, IFormFile file, {IdType} id) { }
115
+ ```
116
+
117
+ ### Relationship Hooks
118
+
119
+ ```csharp
120
+ // Customize the base query for M2M autocomplete/dropdown
121
+ protected virtual async Task<IQueryable<{Related}>> GetAll{Property}QueryFor{Entity}(
122
+ IQueryable<{Related}> query) { return query; }
123
+ ```
124
+
125
+ ## All Hooks Run Inside Transactions
126
+
127
+ Every generated method wraps its logic in `_context.WithTransactionAsync(...)`. Nested calls reuse the existing transaction. You do **not** need to start your own transaction in hooks.
128
+
129
+ **Generated CRUD operations flush the change tracker before commit**, so you can stage tracked writes — an entity `Add`/`Update`, or `IOutbox.Enqueue` — inside any `OnBefore...` hook (including `OnBefore{Entity}Delete`) and they persist atomically with the operation; no manual `SaveChangesAsync`. This holds even though the delete path deletes via untracked bulk `ExecuteDeleteAsync` — the operation still flushes whatever the hook staged. The catch: `WithTransactionAsync`'s clean-tracker-at-commit guard is a backstop, so if you stage a tracked write in a **custom** (non-hook) `WithTransactionAsync` block, you must `SaveChangesAsync` it yourself or the guard throws.
130
+
131
+ If you need a transaction in **custom** (non-hook) methods:
132
+
133
+ ```csharp
134
+ await _context.WithTransactionAsync(async () =>
135
+ {
136
+ // all DB operations here are transactional
137
+ });
138
+ ```
139
+
140
+ ## Exception Types
141
+
142
+ | Type | HTTP Status | When to Use |
143
+ |---|---|---|
144
+ | `BusinessException(message)` | 400 | Validation the user can trigger through normal UI usage |
145
+ | `SecurityViolationException()` | 403 | Impossible conditions, tampering, unauthorized access |
146
+
147
+ ```csharp
148
+ throw new BusinessException("Sale price must be less than regular price.");
149
+ throw new SecurityViolationException(); // logs detailed message server-side, returns generic error
150
+ ```
151
+
152
+ Both surface in the admin UI automatically — the global HTTP-error interceptor toasts the `BusinessException` message (and a safe generic message for everything else). Throw and return; don't build a parallel error channel or per-call frontend handling.
153
+
154
+ ## PostgreSQL MARS Pitfall
155
+
156
+ EF Core on PostgreSQL does **not** support Multiple Active Result Sets. You'll get a `NpgsqlOperationInProgressException` if you enumerate two queries concurrently.
157
+
158
+ **Fix 1 — Materialize with `.Select()` before starting another query:**
159
+
160
+ ```csharp
161
+ // BAD — lazy enumeration holds the connection open
162
+ var dict = await _context.DbSet<Product>().ToDictionaryAsync(x => x.Id, x => x.Name);
163
+
164
+ // GOOD — materialize first
165
+ var dict = await _context.DbSet<Product>()
166
+ .Select(x => new { x.Id, x.Name })
167
+ .ToDictionaryAsync(x => x.Id, x => x.Name);
168
+ ```
169
+
170
+ **Fix 2 — Use `.Include()` instead of lazy loading navigation properties:**
171
+
172
+ ```csharp
173
+ // BAD — accessing Product.Category triggers lazy load while connection is busy
174
+ var products = await _context.DbSet<Product>().ToListAsync();
175
+ var names = products.Select(p => p.Category.Name); // MARS error
176
+
177
+ // GOOD — eager load
178
+ var products = await _context.DbSet<Product>()
179
+ .Include(p => p.Category)
180
+ .ToListAsync();
181
+ ```
182
+
183
+ ## Real-World Example
184
+
185
+ ```csharp
186
+ public class ProductService : ProductServiceGenerated
187
+ {
188
+ private readonly MeilisearchService _meilisearchService;
189
+ private readonly StorefrontRevalidationService _storefrontRevalidationService;
190
+
191
+ public ProductService(
192
+ EntityServiceDependencies deps,
193
+ MeilisearchService meilisearchService,
194
+ StorefrontRevalidationService storefrontRevalidationService
195
+ ) : base(deps)
196
+ {
197
+ _meilisearchService = meilisearchService;
198
+ _storefrontRevalidationService = storefrontRevalidationService;
199
+ }
200
+
201
+ protected override async Task OnBeforeSaveProductAndReturnMainUIFormDTO(
202
+ ProductSaveBodyDTO saveBodyDTO)
203
+ {
204
+ // Validate variant prices
205
+ foreach (ProductVariantSaveBodyDTO v in saveBodyDTO.OrderedProductVariantsSaveBodyDTO)
206
+ {
207
+ if (v.ProductVariantDTO.SalePrice >= v.ProductVariantDTO.Price)
208
+ throw new BusinessException("Sale price must be less than regular price.");
209
+ }
210
+
211
+ // Compute aggregate fields
212
+ saveBodyDTO.ProductDTO.Price = saveBodyDTO.OrderedProductVariantsSaveBodyDTO[0]
213
+ .ProductVariantDTO.Price;
214
+ saveBodyDTO.ProductDTO.Stock = saveBodyDTO.OrderedProductVariantsSaveBodyDTO
215
+ .Sum(v => v.ProductVariantDTO.Stock ?? 0);
216
+ }
217
+
218
+ protected override async Task OnAfterSaveProductAndReturnMainUIFormDTO(
219
+ ProductSaveBodyDTO saveBodyDTO,
220
+ ProductMainUIFormDTO mainUIFormDTO)
221
+ {
222
+ // Cross-entity service call
223
+ ProductVariantServiceGenerated variantService =
224
+ _deps.ServiceProvider.GetRequiredService<ProductVariantServiceGenerated>();
225
+
226
+ // Side effects: indexing, cache invalidation
227
+ await _meilisearchService.IndexProduct(mainUIFormDTO.ProductDTO.Id);
228
+ _storefrontRevalidationService.RevalidateProducts(mainUIFormDTO.ProductDTO.Slug);
229
+ }
230
+ }
231
+ ```