@atlashub/smartstack-cli 4.35.0 → 4.36.0
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/index.js +28 -32
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +29 -10
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/skills/apex/references/checks/seed-checks.sh +1 -1
- package/templates/skills/apex/references/core-seed-data.md +39 -21
- package/templates/skills/application/references/application-roles-template.md +14 -8
- package/templates/skills/application/references/provider-template.md +32 -20
- package/templates/skills/application/templates-frontend.md +294 -2
- package/templates/skills/application/templates-seed.md +23 -11
- package/templates/skills/audit-route/SKILL.md +107 -0
- package/templates/skills/audit-route/references/routing-pattern.md +129 -0
- package/templates/skills/audit-route/steps/step-00-init.md +128 -0
- package/templates/skills/audit-route/steps/step-01-inventory.md +157 -0
- package/templates/skills/audit-route/steps/step-02-conformity.md +193 -0
- package/templates/skills/audit-route/steps/step-03-report.md +201 -0
- package/templates/skills/dev-start/SKILL.md +12 -2
- package/templates/skills/efcore/SKILL.md +219 -67
- package/templates/agents/efcore/conflicts.md +0 -114
- package/templates/agents/efcore/db-deploy.md +0 -86
- package/templates/agents/efcore/db-reset.md +0 -98
- package/templates/agents/efcore/db-seed.md +0 -73
- package/templates/agents/efcore/db-status.md +0 -97
- package/templates/agents/efcore/scan.md +0 -124
- package/templates/skills/efcore/references/both-contexts.md +0 -32
- package/templates/skills/efcore/references/destructive-operations.md +0 -38
- package/templates/skills/efcore/steps/db/step-deploy.md +0 -217
- package/templates/skills/efcore/steps/db/step-reset.md +0 -186
- package/templates/skills/efcore/steps/db/step-seed.md +0 -166
- package/templates/skills/efcore/steps/db/step-status.md +0 -173
- package/templates/skills/efcore/steps/migration/step-00-init.md +0 -102
- package/templates/skills/efcore/steps/migration/step-01-check.md +0 -164
- package/templates/skills/efcore/steps/migration/step-02-create.md +0 -160
- package/templates/skills/efcore/steps/migration/step-03-validate.md +0 -168
- package/templates/skills/efcore/steps/rebase-snapshot/step-00-init.md +0 -173
- package/templates/skills/efcore/steps/rebase-snapshot/step-01-backup.md +0 -100
- package/templates/skills/efcore/steps/rebase-snapshot/step-02-fetch.md +0 -115
- package/templates/skills/efcore/steps/rebase-snapshot/step-03-create.md +0 -112
- package/templates/skills/efcore/steps/rebase-snapshot/step-04-validate.md +0 -157
- package/templates/skills/efcore/steps/shared/step-00-init.md +0 -131
- package/templates/skills/efcore/steps/squash/step-00-init.md +0 -141
- package/templates/skills/efcore/steps/squash/step-01-backup.md +0 -120
- package/templates/skills/efcore/steps/squash/step-02-fetch.md +0 -168
- package/templates/skills/efcore/steps/squash/step-03-create.md +0 -184
- package/templates/skills/efcore/steps/squash/step-04-validate.md +0 -174
package/package.json
CHANGED
|
@@ -249,7 +249,7 @@ if [ -n "$PROVIDER" ]; then
|
|
|
249
249
|
echo "CRITICAL: Translation seed data inserts without idempotency guard in $PROVIDER"
|
|
250
250
|
echo "Fix: Before each NavigationTranslations.Add block, check existence:"
|
|
251
251
|
echo " if (!await context.NavigationTranslations.AnyAsync("
|
|
252
|
-
echo " t => t.EntityId ==
|
|
252
|
+
echo " t => t.EntityId == mod1Entity.Id // actual DB ID, NOT seed-time GUID"
|
|
253
253
|
echo " && t.EntityType == NavigationEntityType.Module, ct))"
|
|
254
254
|
echo " { foreach (var t in ...) { context.NavigationTranslations.Add(...); } }"
|
|
255
255
|
echo "The unique index IX_nav_Translations_EntityType_EntityId_LanguageCode will crash on duplicates."
|
|
@@ -109,6 +109,7 @@ public static class NavigationApplicationSeedData
|
|
|
109
109
|
return new NavigationApplicationSeedEntry
|
|
110
110
|
{
|
|
111
111
|
Id = ApplicationId,
|
|
112
|
+
Zone = ApplicationZone.Business,
|
|
112
113
|
Code = "{appCode}",
|
|
113
114
|
Label = "{appLabel_en}",
|
|
114
115
|
Description = "{appDesc_en}",
|
|
@@ -198,6 +199,7 @@ public static class NavigationApplicationSeedData
|
|
|
198
199
|
public class NavigationApplicationSeedEntry
|
|
199
200
|
{
|
|
200
201
|
public Guid Id { get; init; }
|
|
202
|
+
public ApplicationZone Zone { get; init; }
|
|
201
203
|
public string Code { get; init; } = null!;
|
|
202
204
|
public string Label { get; init; } = null!;
|
|
203
205
|
public string? Description { get; init; }
|
|
@@ -1007,11 +1009,12 @@ public class RolePermissionSeedEntry
|
|
|
1007
1009
|
|
|
1008
1010
|
| Rule | Description |
|
|
1009
1011
|
|------|-------------|
|
|
1010
|
-
| Factory methods | `
|
|
1012
|
+
| Factory methods | `NavigationApplication.Create(zone, ...)`, `Role.Create(name, shortName, category, ...)`, `Permission.CreateForModule(...)` — NEVER `new Entity()` |
|
|
1011
1013
|
| Idempotence | Each Seed method checks existence before inserting |
|
|
1012
1014
|
| Execution order | Navigation → Roles → Permissions → RolePermissions (roles MUST exist before mapping) |
|
|
1013
1015
|
| SaveChanges per group | Navigation -> save -> Roles -> save -> Permissions -> save -> RolePermissions -> save |
|
|
1014
|
-
| FK resolution by Code | Parent entities (modules, roles) found by `Code
|
|
1016
|
+
| FK resolution by Code | Parent entities (applications, modules, roles) found by `Code` from DB, not seed-time GUID |
|
|
1017
|
+
| Translation EntityId | ALWAYS use actual DB ID (`app.Id`, `mod1Entity.Id`, `actualSection.Id`), NEVER seed-time GUID from SeedData classes |
|
|
1015
1018
|
| DI registration | `services.AddScoped<IClientSeedDataProvider, {AppPascalName}SeedDataProvider>()` |
|
|
1016
1019
|
|
|
1017
1020
|
### Template
|
|
@@ -1054,7 +1057,7 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1054
1057
|
else
|
|
1055
1058
|
{
|
|
1056
1059
|
app = NavigationApplication.Create(
|
|
1057
|
-
appEntry.Code, appEntry.Label,
|
|
1060
|
+
appEntry.Zone, appEntry.Code, appEntry.Label,
|
|
1058
1061
|
appEntry.Description, appEntry.Icon, appEntry.IconType,
|
|
1059
1062
|
appEntry.Route, appEntry.DisplayOrder);
|
|
1060
1063
|
context.NavigationApplications.Add(app);
|
|
@@ -1064,7 +1067,7 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1064
1067
|
foreach (var t in NavigationApplicationSeedData.GetTranslationEntries())
|
|
1065
1068
|
{
|
|
1066
1069
|
context.NavigationTranslations.Add(
|
|
1067
|
-
NavigationTranslation.Create(t.EntityType,
|
|
1070
|
+
NavigationTranslation.Create(t.EntityType, app.Id, t.LanguageCode, t.Label, t.Description));
|
|
1068
1071
|
}
|
|
1069
1072
|
await ((DbContext)context).SaveChangesAsync(ct);
|
|
1070
1073
|
}
|
|
@@ -1097,14 +1100,15 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1097
1100
|
// --- Module translations (idempotent — unique index IX_nav_Translations_EntityType_EntityId_LanguageCode) ---
|
|
1098
1101
|
// Always check existence before inserting translations to avoid duplicate key errors
|
|
1099
1102
|
// on re-runs, partial failures, or DB reset scenarios.
|
|
1103
|
+
// CRITICAL: Use mod1Entity.Id (actual DB ID), NOT seed-time GUID from SeedData class.
|
|
1100
1104
|
if (!await context.NavigationTranslations.AnyAsync(
|
|
1101
|
-
t => t.EntityId ==
|
|
1105
|
+
t => t.EntityId == mod1Entity.Id
|
|
1102
1106
|
&& t.EntityType == NavigationEntityType.Module, ct))
|
|
1103
1107
|
{
|
|
1104
1108
|
foreach (var t in {Module1Pascal}NavigationSeedData.GetTranslationEntries())
|
|
1105
1109
|
{
|
|
1106
1110
|
context.NavigationTranslations.Add(
|
|
1107
|
-
NavigationTranslation.Create(t.EntityType,
|
|
1111
|
+
NavigationTranslation.Create(t.EntityType, mod1Entity.Id, t.LanguageCode, t.Label, t.Description));
|
|
1108
1112
|
}
|
|
1109
1113
|
}
|
|
1110
1114
|
// Repeat for each module...
|
|
@@ -1129,16 +1133,21 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1129
1133
|
await ((DbContext)context).SaveChangesAsync(ct);
|
|
1130
1134
|
|
|
1131
1135
|
// --- Section translations (idempotent — check before inserting) ---
|
|
1136
|
+
// CRITICAL: secEntry.Id is a seed-time GUID ≠ actual DB ID.
|
|
1137
|
+
// Resolve actual section from DB by Code+ModuleId to get the real ID.
|
|
1132
1138
|
foreach (var secEntry in {Module1Pascal}NavigationSeedData.GetSectionEntries(mod1Entity.Id))
|
|
1133
1139
|
{
|
|
1140
|
+
var actualSection = await context.NavigationSections
|
|
1141
|
+
.FirstAsync(s => s.Code == secEntry.Code && s.ModuleId == mod1Entity.Id, ct);
|
|
1142
|
+
|
|
1134
1143
|
if (!await context.NavigationTranslations.AnyAsync(
|
|
1135
|
-
t => t.EntityId ==
|
|
1144
|
+
t => t.EntityId == actualSection.Id && t.EntityType == NavigationEntityType.Section, ct))
|
|
1136
1145
|
{
|
|
1137
1146
|
foreach (var t in {Module1Pascal}NavigationSeedData.GetSectionTranslationEntries()
|
|
1138
1147
|
.Where(st => st.EntityId == secEntry.Id))
|
|
1139
1148
|
{
|
|
1140
1149
|
context.NavigationTranslations.Add(
|
|
1141
|
-
NavigationTranslation.Create(t.EntityType,
|
|
1150
|
+
NavigationTranslation.Create(t.EntityType, actualSection.Id, t.LanguageCode, t.Label, t.Description));
|
|
1142
1151
|
}
|
|
1143
1152
|
}
|
|
1144
1153
|
}
|
|
@@ -1174,10 +1183,13 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1174
1183
|
|
|
1175
1184
|
public async Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct)
|
|
1176
1185
|
{
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1186
|
+
// Resolve application from DB by Code — NOT seed-time GUID
|
|
1187
|
+
var app = await context.NavigationApplications
|
|
1188
|
+
.FirstAsync(a => a.Code == "{appCode}", ct);
|
|
1189
|
+
|
|
1190
|
+
// Check idempotence — verify by Code, scoped by actual DB ApplicationId
|
|
1179
1191
|
var existingRoleCodes = await context.Roles
|
|
1180
|
-
.Where(r => r.ApplicationId ==
|
|
1192
|
+
.Where(r => r.ApplicationId == app.Id
|
|
1181
1193
|
&& (r.Code == "admin" || r.Code == "manager" || r.Code == "contributor" || r.Code == "viewer"))
|
|
1182
1194
|
.Select(r => r.Code)
|
|
1183
1195
|
.ToListAsync(ct);
|
|
@@ -1188,11 +1200,13 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1188
1200
|
if (existingRoleCodes.Contains(entry.Code)) continue; // Skip if already exists
|
|
1189
1201
|
|
|
1190
1202
|
var role = Role.Create(
|
|
1191
|
-
entry.
|
|
1192
|
-
entry.
|
|
1193
|
-
|
|
1194
|
-
entry.
|
|
1195
|
-
entry.IsSystem
|
|
1203
|
+
name: entry.Name,
|
|
1204
|
+
shortName: entry.Code,
|
|
1205
|
+
category: RoleCategory.Application,
|
|
1206
|
+
description: entry.Description,
|
|
1207
|
+
isSystem: entry.IsSystem,
|
|
1208
|
+
applicationId: app.Id,
|
|
1209
|
+
code: entry.Code);
|
|
1196
1210
|
context.Roles.Add(role);
|
|
1197
1211
|
}
|
|
1198
1212
|
await ((DbContext)context).SaveChangesAsync(ct);
|
|
@@ -1231,13 +1245,17 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
1231
1245
|
.AnyAsync(rp => rp.Permission!.Path.StartsWith("{appCode}."), ct);
|
|
1232
1246
|
if (exists) return;
|
|
1233
1247
|
|
|
1248
|
+
// Resolve application from DB by Code — NOT seed-time GUID
|
|
1249
|
+
var app = await context.NavigationApplications
|
|
1250
|
+
.FirstAsync(a => a.Code == "{appCode}", ct);
|
|
1251
|
+
|
|
1234
1252
|
// Resolve roles by Code from DB — do not use hardcoded GUIDs.
|
|
1235
1253
|
// Application-scoped roles (admin, manager, contributor, viewer) are created by
|
|
1236
1254
|
// SeedRolesAsync() above. System roles use their own IDs that do not match
|
|
1237
1255
|
// DeterministicGuid("role:admin").
|
|
1238
1256
|
// Load THIS application's roles + system roles only
|
|
1239
1257
|
var roles = await context.Roles
|
|
1240
|
-
.Where(r => r.ApplicationId ==
|
|
1258
|
+
.Where(r => r.ApplicationId == app.Id || r.IsSystem)
|
|
1241
1259
|
.ToListAsync(ct);
|
|
1242
1260
|
|
|
1243
1261
|
// Resolve permissions
|
|
@@ -1333,11 +1351,11 @@ Before marking the task as completed, verify ALL:
|
|
|
1333
1351
|
|
|
1334
1352
|
**Application-Level (FIRST — before modules):**
|
|
1335
1353
|
- [ ] `NavigationApplicationSeedData.cs` created (once per application, at `Infrastructure/Persistence/Seeding/Data/`)
|
|
1336
|
-
- [ ] Application GUID is
|
|
1337
|
-
- [ ] GetApplicationEntry() takes no parameters (no contextId)
|
|
1338
|
-
- [ ] Application translations created (4 languages: fr, en, it, de, EntityType = Application)
|
|
1354
|
+
- [ ] Application GUID is random (`Guid.NewGuid()`) — FK resolution is by Code lookup, not fixed ID
|
|
1355
|
+
- [ ] GetApplicationEntry() takes no parameters (no contextId), includes `Zone = ApplicationZone.Business`
|
|
1356
|
+
- [ ] Application translations created (4 languages: fr, en, it, de, EntityType = Application), using `app.Id` (actual DB ID) for EntityId
|
|
1339
1357
|
- [ ] `IClientSeedDataProvider.SeedNavigationAsync()` uses `NavigationApplicationSeedData` (NO hardcoded `{appLabel_en}` / `{appIcon}` placeholders)
|
|
1340
|
-
- [ ] `ApplicationRolesSeedData.ApplicationId` references `NavigationApplicationSeedData.ApplicationId` (
|
|
1358
|
+
- [ ] `ApplicationRolesSeedData.ApplicationId` references `NavigationApplicationSeedData.ApplicationId` (DTO only — provider code resolves from DB by Code)
|
|
1341
1359
|
|
|
1342
1360
|
**Module-Level:**
|
|
1343
1361
|
- [ ] Random GUIDs via `Guid.NewGuid()` (no deterministic/sequential/fixed values)
|
|
@@ -147,20 +147,26 @@ Add a new method `SeedRolesAsync()` to the provider:
|
|
|
147
147
|
```csharp
|
|
148
148
|
public async Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct)
|
|
149
149
|
{
|
|
150
|
-
//
|
|
150
|
+
// Resolve application from DB by Code — NOT seed-time GUID
|
|
151
|
+
var app = await context.NavigationApplications
|
|
152
|
+
.FirstAsync(a => a.Code == "{app_code}", ct);
|
|
153
|
+
|
|
154
|
+
// Check idempotence — scoped by actual DB ApplicationId
|
|
151
155
|
var exists = await context.Roles
|
|
152
|
-
.AnyAsync(r => r.ApplicationId ==
|
|
156
|
+
.AnyAsync(r => r.ApplicationId == app.Id, ct);
|
|
153
157
|
if (exists) return;
|
|
154
158
|
|
|
155
159
|
// Create application-scoped roles using factory method
|
|
156
160
|
foreach (var entry in ApplicationRolesSeedData.GetRoleEntries())
|
|
157
161
|
{
|
|
158
162
|
var role = Role.Create(
|
|
159
|
-
entry.
|
|
160
|
-
entry.
|
|
161
|
-
|
|
162
|
-
entry.
|
|
163
|
-
entry.IsSystem
|
|
163
|
+
name: entry.Name,
|
|
164
|
+
shortName: entry.Code,
|
|
165
|
+
category: RoleCategory.Application,
|
|
166
|
+
description: entry.Description,
|
|
167
|
+
isSystem: entry.IsSystem,
|
|
168
|
+
applicationId: app.Id,
|
|
169
|
+
code: entry.Code);
|
|
164
170
|
|
|
165
171
|
context.Roles.Add(role);
|
|
166
172
|
}
|
|
@@ -205,7 +211,7 @@ Before marking the task as completed, verify:
|
|
|
205
211
|
## Notes
|
|
206
212
|
|
|
207
213
|
- **Application ID source:** Read from the navigation application created in `SeedNavigationAsync()` or from `{AppPascal}NavigationSeedData.cs`
|
|
208
|
-
- **Role factory method:** Use `Role.Create(
|
|
214
|
+
- **Role factory method:** Use `Role.Create(name, shortName, category, description, isSystem, applicationId: ..., code: ...)` from SmartStack.Domain
|
|
209
215
|
- **Code uniqueness:** Role codes must be unique within the application scope
|
|
210
216
|
- **System roles:** These are NOT system roles (IsSystem = false) - they are application-scoped roles
|
|
211
217
|
- **Tenant isolation:** Application-scoped roles are automatically tenant-isolated via the Core authorization system
|
|
@@ -43,7 +43,7 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
43
43
|
else
|
|
44
44
|
{
|
|
45
45
|
app = NavigationApplication.Create(
|
|
46
|
-
appEntry.Code, appEntry.Label,
|
|
46
|
+
ApplicationZone.Business, appEntry.Code, appEntry.Label,
|
|
47
47
|
appEntry.Description, appEntry.Icon, appEntry.IconType,
|
|
48
48
|
appEntry.Route, appEntry.DisplayOrder);
|
|
49
49
|
context.NavigationApplications.Add(app);
|
|
@@ -53,7 +53,7 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
53
53
|
foreach (var t in NavigationApplicationSeedData.GetTranslationEntries())
|
|
54
54
|
{
|
|
55
55
|
context.NavigationTranslations.Add(
|
|
56
|
-
NavigationTranslation.Create(t.EntityType,
|
|
56
|
+
NavigationTranslation.Create(t.EntityType, app.Id, t.LanguageCode, t.Label, t.Description));
|
|
57
57
|
}
|
|
58
58
|
await ((DbContext)context).SaveChangesAsync(ct);
|
|
59
59
|
}
|
|
@@ -72,18 +72,24 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
72
72
|
// --- Module translations (IDEMPOTENT — check before inserting) ---
|
|
73
73
|
// CRITICAL: nav_Translations has unique index IX_nav_Translations_EntityType_EntityId_LanguageCode
|
|
74
74
|
// Always guard with AnyAsync to prevent duplicate key errors on re-runs.
|
|
75
|
+
// CRITICAL: Use modEntity.Id (actual DB ID), NOT seed-time GUID from SeedData class.
|
|
75
76
|
// if (!await context.NavigationTranslations.AnyAsync(
|
|
76
|
-
// t => t.EntityId ==
|
|
77
|
+
// t => t.EntityId == mod1Entity.Id
|
|
77
78
|
// && t.EntityType == NavigationEntityType.Module, ct))
|
|
78
|
-
// { foreach (var t in {Module}NavigationSeedData.GetTranslationEntries())
|
|
79
|
+
// { foreach (var t in {Module}NavigationSeedData.GetTranslationEntries())
|
|
80
|
+
// { context.NavigationTranslations.Add(
|
|
81
|
+
// NavigationTranslation.Create(t.EntityType, mod1Entity.Id, t.LanguageCode, t.Label, t.Description)); } }
|
|
79
82
|
await ((DbContext)context).SaveChangesAsync(ct);
|
|
80
83
|
|
|
81
84
|
// --- Sections (idempotent per-section) ---
|
|
82
85
|
// Check each section before inserting: AnyAsync(s => s.Code == secEntry.Code && s.ModuleId == ...)
|
|
83
86
|
|
|
84
87
|
// --- Section translations (IDEMPOTENT — same guard pattern as module translations) ---
|
|
88
|
+
// CRITICAL: secEntry.Id is a seed-time GUID ≠ actual DB ID.
|
|
89
|
+
// Resolve actual section from DB: var actualSection = await context.NavigationSections
|
|
90
|
+
// .FirstAsync(s => s.Code == secEntry.Code && s.ModuleId == modEntity.Id, ct);
|
|
85
91
|
// if (!await context.NavigationTranslations.AnyAsync(
|
|
86
|
-
// t => t.EntityId ==
|
|
92
|
+
// t => t.EntityId == actualSection.Id && t.EntityType == NavigationEntityType.Section, ct))
|
|
87
93
|
|
|
88
94
|
// --- Resources (idempotent — use ACTUAL section IDs from DB) ---
|
|
89
95
|
// CRITICAL: NavigationSection.Create() generates its own random ID.
|
|
@@ -95,10 +101,13 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
95
101
|
|
|
96
102
|
public async Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct)
|
|
97
103
|
{
|
|
98
|
-
//
|
|
99
|
-
var
|
|
104
|
+
// Resolve application from DB by Code — NOT seed-time GUID
|
|
105
|
+
var app = await context.NavigationApplications
|
|
106
|
+
.FirstAsync(a => a.Code == "{app_code}", ct);
|
|
107
|
+
|
|
108
|
+
// Check idempotence — scoped by actual DB ApplicationId
|
|
100
109
|
var exists = await context.Roles
|
|
101
|
-
.AnyAsync(r => r.ApplicationId ==
|
|
110
|
+
.AnyAsync(r => r.ApplicationId == app.Id, ct);
|
|
102
111
|
if (exists) return;
|
|
103
112
|
|
|
104
113
|
// Create application-scoped roles (Admin, Manager, Contributor, Viewer)
|
|
@@ -106,11 +115,13 @@ public class {AppPascalName}SeedDataProvider : IClientSeedDataProvider
|
|
|
106
115
|
foreach (var entry in ApplicationRolesSeedData.GetRoleEntries())
|
|
107
116
|
{
|
|
108
117
|
var role = Role.Create(
|
|
109
|
-
entry.
|
|
110
|
-
entry.
|
|
111
|
-
|
|
112
|
-
entry.
|
|
113
|
-
entry.IsSystem
|
|
118
|
+
name: entry.Name,
|
|
119
|
+
shortName: entry.Code,
|
|
120
|
+
category: RoleCategory.Application,
|
|
121
|
+
description: entry.Description,
|
|
122
|
+
isSystem: entry.IsSystem,
|
|
123
|
+
applicationId: app.Id,
|
|
124
|
+
code: entry.Code);
|
|
114
125
|
context.Roles.Add(role);
|
|
115
126
|
}
|
|
116
127
|
await ((DbContext)context).SaveChangesAsync(ct);
|
|
@@ -167,11 +178,12 @@ services.AddScoped<IClientSeedDataProvider, {AppPascalName}SeedDataProvider>();
|
|
|
167
178
|
|
|
168
179
|
## Critical Rules
|
|
169
180
|
|
|
170
|
-
1. **Factory methods mandatory**: `NavigationModule.Create(...)`, `Role.Create(...)`, `Permission.CreateForModule(...)`, `RolePermission.Create(...)` — NEVER `new Entity()`
|
|
181
|
+
1. **Factory methods mandatory**: `NavigationApplication.Create(zone, ...)`, `NavigationModule.Create(...)`, `Role.Create(name, shortName, category, ...)`, `Permission.CreateForModule(...)`, `RolePermission.Create(...)` — NEVER `new Entity()`
|
|
171
182
|
2. **Idempotence**: Each Seed method checks existence before inserting. **Translations require explicit `AnyAsync` guard** — unique index `IX_nav_Translations_EntityType_EntityId_LanguageCode` causes crashes on duplicate inserts
|
|
172
|
-
3. **Translation
|
|
173
|
-
4. **
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
183
|
+
3. **Translation EntityId**: ALWAYS use actual DB ID (e.g. `app.Id`, `mod1Entity.Id`, `actualSection.Id`), NEVER seed-time GUID from SeedData classes
|
|
184
|
+
4. **Translation guard pattern**: `if (!await context.NavigationTranslations.AnyAsync(t => t.EntityId == {actualDbId} && t.EntityType == ..., ct))` — MANDATORY before every translation batch insert
|
|
185
|
+
5. **SaveChangesAsync per group**: Navigation → save → Roles → save → Permissions → save → RolePermissions → save
|
|
186
|
+
6. **Execution order**: `SeedRolesAsync()` MUST be called BEFORE `SeedRolePermissionsAsync()` (roles must exist before mapping)
|
|
187
|
+
7. **Random GUIDs**: ALWAYS use `Guid.NewGuid()` — resolve FKs by Code lookup, not fixed IDs
|
|
188
|
+
8. **Resolve FK by Code**: Parent entities (applications, modules, roles) are found by `Code` from DB, not by seed-time GUID
|
|
189
|
+
9. **Order property**: Use `100` as default. If multiple providers exist, they run in Order sequence
|
|
@@ -79,6 +79,274 @@ export function $MODULE_PASCALPage() {
|
|
|
79
79
|
|
|
80
80
|
---
|
|
81
81
|
|
|
82
|
+
## TEMPLATE: DETAIL PAGE
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// pages/$APPLICATION/$MODULE/$MODULE_PASCALDetailPage.tsx
|
|
86
|
+
|
|
87
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
88
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
89
|
+
import { useTranslation } from 'react-i18next';
|
|
90
|
+
import { ArrowLeft, Pencil, Trash2, Loader2 } from 'lucide-react';
|
|
91
|
+
import { Breadcrumb } from '@/components/ui/Breadcrumb';
|
|
92
|
+
import { $moduleApi, type $ENTITY_PASCALDto } from '@/services/api/$moduleApi';
|
|
93
|
+
|
|
94
|
+
export function $MODULE_PASCALDetailPage() {
|
|
95
|
+
// ⚠️ CRITICAL: DynamicRouter generates /:id — use destructuring rename
|
|
96
|
+
const { id: $entityId } = useParams<{ id: string }>();
|
|
97
|
+
const navigate = useNavigate();
|
|
98
|
+
const { t } = useTranslation(['$module', 'common']);
|
|
99
|
+
|
|
100
|
+
const [$entity, set$ENTITY_PASCAL] = useState<$ENTITY_PASCALDto | null>(null);
|
|
101
|
+
const [loading, setLoading] = useState(true);
|
|
102
|
+
const [error, setError] = useState<string | null>(null);
|
|
103
|
+
|
|
104
|
+
const loadData = useCallback(async () => {
|
|
105
|
+
if (!$entityId) return;
|
|
106
|
+
try {
|
|
107
|
+
setLoading(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
const data = await $moduleApi.getById($entityId);
|
|
110
|
+
set$ENTITY_PASCAL(data);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
setError(t('common:errors.loadFailed'));
|
|
113
|
+
console.error('Failed to load $entity:', err);
|
|
114
|
+
} finally {
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
}, [$entityId, t]);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
loadData();
|
|
121
|
+
}, [loadData]);
|
|
122
|
+
|
|
123
|
+
const handleDelete = async () => {
|
|
124
|
+
if (!$entityId || !confirm(t('common:confirmDelete'))) return;
|
|
125
|
+
try {
|
|
126
|
+
await $moduleApi.delete($entityId);
|
|
127
|
+
navigate('..');
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error('Failed to delete:', err);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (loading) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex items-center justify-center py-12">
|
|
136
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (error || !$entity) {
|
|
142
|
+
return (
|
|
143
|
+
<div className="p-6">
|
|
144
|
+
<p className="text-[var(--error-text)]">{error || t('common:errors.notFound')}</p>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="space-y-6">
|
|
151
|
+
<Breadcrumb
|
|
152
|
+
items={[
|
|
153
|
+
{ label: t('$module:title'), href: '/$APPLICATION_KEBAB/$MODULE_KEBAB' },
|
|
154
|
+
{ label: $entity.name },
|
|
155
|
+
]}
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<div className="flex items-center justify-between">
|
|
159
|
+
<div className="flex items-center gap-3">
|
|
160
|
+
<button onClick={() => navigate('..')} className="p-2 hover:bg-[var(--bg-hover)]" style={{ borderRadius: 'var(--radius-button)' }}>
|
|
161
|
+
<ArrowLeft className="w-5 h-5" />
|
|
162
|
+
</button>
|
|
163
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{$entity.name}</h1>
|
|
164
|
+
</div>
|
|
165
|
+
<div className="flex items-center gap-2">
|
|
166
|
+
<button
|
|
167
|
+
onClick={() => navigate('edit')}
|
|
168
|
+
className="flex items-center gap-2 px-4 py-2 bg-[var(--color-accent-500)] hover:bg-[var(--color-accent-600)] text-white font-medium transition-colors"
|
|
169
|
+
style={{ borderRadius: 'var(--radius-button)' }}
|
|
170
|
+
>
|
|
171
|
+
<Pencil className="w-4 h-4" />
|
|
172
|
+
{t('common:actions.edit')}
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
onClick={handleDelete}
|
|
176
|
+
className="flex items-center gap-2 px-4 py-2 bg-[var(--error-bg)] hover:bg-[var(--error-border)] text-[var(--error-text)] font-medium transition-colors"
|
|
177
|
+
style={{ borderRadius: 'var(--radius-button)' }}
|
|
178
|
+
>
|
|
179
|
+
<Trash2 className="w-4 h-4" />
|
|
180
|
+
{t('common:actions.delete')}
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Detail content — adapt to your entity */}
|
|
186
|
+
<div className="bg-[var(--bg-card)] border border-[var(--item-color-border)] p-6" style={{ borderRadius: 'var(--radius-card)' }}>
|
|
187
|
+
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
188
|
+
<div>
|
|
189
|
+
<dt className="text-sm font-medium text-[var(--text-secondary)]">{t('$module:columns.name')}</dt>
|
|
190
|
+
<dd className="mt-1 text-[var(--text-primary)]">{$entity.name}</dd>
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<dt className="text-sm font-medium text-[var(--text-secondary)]">{t('$module:columns.description')}</dt>
|
|
194
|
+
<dd className="mt-1 text-[var(--text-primary)]">{$entity.description || '-'}</dd>
|
|
195
|
+
</div>
|
|
196
|
+
</dl>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## TEMPLATE: EDIT PAGE
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
// pages/$APPLICATION/$MODULE/$MODULE_PASCALEditPage.tsx
|
|
209
|
+
|
|
210
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
211
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
212
|
+
import { useTranslation } from 'react-i18next';
|
|
213
|
+
import { ArrowLeft, Save, Loader2 } from 'lucide-react';
|
|
214
|
+
import { Breadcrumb } from '@/components/ui/Breadcrumb';
|
|
215
|
+
import { $moduleApi, type $ENTITY_PASCALDto, type Update$ENTITY_PASCALRequest } from '@/services/api/$moduleApi';
|
|
216
|
+
|
|
217
|
+
export function $MODULE_PASCALEditPage() {
|
|
218
|
+
// ⚠️ CRITICAL: DynamicRouter generates /:id/edit — use destructuring rename
|
|
219
|
+
const { id: $entityId } = useParams<{ id: string }>();
|
|
220
|
+
const navigate = useNavigate();
|
|
221
|
+
const { t } = useTranslation(['$module', 'common']);
|
|
222
|
+
|
|
223
|
+
const [data, setData] = useState<$ENTITY_PASCALDto | null>(null);
|
|
224
|
+
const [form, setForm] = useState<Update$ENTITY_PASCALRequest>({ name: '', description: '' });
|
|
225
|
+
const [loading, setLoading] = useState(true);
|
|
226
|
+
const [saving, setSaving] = useState(false);
|
|
227
|
+
const [error, setError] = useState<string | null>(null);
|
|
228
|
+
|
|
229
|
+
const loadData = useCallback(async () => {
|
|
230
|
+
if (!$entityId) return;
|
|
231
|
+
try {
|
|
232
|
+
setLoading(true);
|
|
233
|
+
const result = await $moduleApi.getById($entityId);
|
|
234
|
+
setData(result);
|
|
235
|
+
setForm({ name: result.name, description: result.description || '' });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
setError(t('common:errors.loadFailed'));
|
|
238
|
+
console.error('Failed to load $entity:', err);
|
|
239
|
+
} finally {
|
|
240
|
+
setLoading(false);
|
|
241
|
+
}
|
|
242
|
+
}, [$entityId, t]);
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
loadData();
|
|
246
|
+
}, [loadData]);
|
|
247
|
+
|
|
248
|
+
const handleSave = async () => {
|
|
249
|
+
if (!$entityId) return;
|
|
250
|
+
try {
|
|
251
|
+
setSaving(true);
|
|
252
|
+
setError(null);
|
|
253
|
+
await $moduleApi.update($entityId, form);
|
|
254
|
+
navigate('..');
|
|
255
|
+
} catch (err) {
|
|
256
|
+
setError(t('common:errors.saveFailed'));
|
|
257
|
+
console.error('Failed to save:', err);
|
|
258
|
+
} finally {
|
|
259
|
+
setSaving(false);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (loading) {
|
|
264
|
+
return (
|
|
265
|
+
<div className="flex items-center justify-center py-12">
|
|
266
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div className="space-y-6">
|
|
273
|
+
<Breadcrumb
|
|
274
|
+
items={[
|
|
275
|
+
{ label: t('$module:title'), href: '/$APPLICATION_KEBAB/$MODULE_KEBAB' },
|
|
276
|
+
{ label: data?.name || '...', href: '..' },
|
|
277
|
+
{ label: t('common:actions.edit') },
|
|
278
|
+
]}
|
|
279
|
+
/>
|
|
280
|
+
|
|
281
|
+
<div className="flex items-center gap-3">
|
|
282
|
+
<button onClick={() => navigate('..')} className="p-2 hover:bg-[var(--bg-hover)]" style={{ borderRadius: 'var(--radius-button)' }}>
|
|
283
|
+
<ArrowLeft className="w-5 h-5" />
|
|
284
|
+
</button>
|
|
285
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
286
|
+
{t('common:actions.edit')} — {data?.name}
|
|
287
|
+
</h1>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{error && (
|
|
291
|
+
<div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)]" style={{ borderRadius: 'var(--radius-card)' }}>
|
|
292
|
+
<span className="text-[var(--error-text)]">{error}</span>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
<div className="bg-[var(--bg-card)] border border-[var(--item-color-border)] p-6" style={{ borderRadius: 'var(--radius-card)' }}>
|
|
297
|
+
<div className="space-y-4 max-w-lg">
|
|
298
|
+
<div>
|
|
299
|
+
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
|
300
|
+
{t('$module:columns.name')}
|
|
301
|
+
</label>
|
|
302
|
+
<input
|
|
303
|
+
type="text"
|
|
304
|
+
value={form.name}
|
|
305
|
+
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
|
|
306
|
+
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] text-sm focus:outline-none focus:border-[var(--color-accent-500)]"
|
|
307
|
+
style={{ borderRadius: 'var(--radius-input)' }}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
<div>
|
|
311
|
+
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
|
312
|
+
{t('$module:columns.description')}
|
|
313
|
+
</label>
|
|
314
|
+
<textarea
|
|
315
|
+
value={form.description || ''}
|
|
316
|
+
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
|
|
317
|
+
rows={3}
|
|
318
|
+
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] text-sm focus:outline-none focus:border-[var(--color-accent-500)]"
|
|
319
|
+
style={{ borderRadius: 'var(--radius-input)' }}
|
|
320
|
+
/>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--border-color)]">
|
|
325
|
+
<button
|
|
326
|
+
onClick={() => navigate('..')}
|
|
327
|
+
className="px-4 py-2 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] font-medium transition-colors"
|
|
328
|
+
style={{ borderRadius: 'var(--radius-button)' }}
|
|
329
|
+
>
|
|
330
|
+
{t('common:actions.cancel')}
|
|
331
|
+
</button>
|
|
332
|
+
<button
|
|
333
|
+
onClick={handleSave}
|
|
334
|
+
disabled={saving}
|
|
335
|
+
className="flex items-center gap-2 px-4 py-2 bg-[var(--color-accent-500)] hover:bg-[var(--color-accent-600)] text-white font-medium transition-colors disabled:opacity-50"
|
|
336
|
+
style={{ borderRadius: 'var(--radius-button)' }}
|
|
337
|
+
>
|
|
338
|
+
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
339
|
+
{t('common:actions.save')}
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
82
350
|
## TEMPLATE: LIST VIEW (Reusable component)
|
|
83
351
|
|
|
84
352
|
```tsx
|
|
@@ -557,6 +825,28 @@ INSERT INTO core.nav_Sections (...)
|
|
|
557
825
|
VALUES (..., ComponentKey = '$APPLICATION_KEBAB.$MODULE_KEBAB', ...);
|
|
558
826
|
```
|
|
559
827
|
|
|
828
|
+
### ⚠️ IMPLICIT ROUTE PARAM CONVENTION
|
|
829
|
+
|
|
830
|
+
DynamicRouter generates implicit sub-routes for `.detail`, `.create`, and `.edit` page keys:
|
|
831
|
+
|
|
832
|
+
| Suffix | URL pattern | Route param |
|
|
833
|
+
|--------|-------------|-------------|
|
|
834
|
+
| `.detail` | `/:id` | `id` |
|
|
835
|
+
| `.create` | `/create` | — |
|
|
836
|
+
| `.edit` | `/:id/edit` | `id` |
|
|
837
|
+
|
|
838
|
+
**The route param is ALWAYS `id`** — never `ticketId`, `userId`, or any entity-specific name.
|
|
839
|
+
|
|
840
|
+
In detail/edit pages, use destructuring rename to keep a readable local variable:
|
|
841
|
+
|
|
842
|
+
```tsx
|
|
843
|
+
// ✅ CORRECT — matches DynamicRouter's /:id pattern
|
|
844
|
+
const { id: $entityId } = useParams<{ id: string }>();
|
|
845
|
+
|
|
846
|
+
// ❌ WRONG — will be undefined because the route param is :id, not :$entityId
|
|
847
|
+
const { $entityId } = useParams<{ $entityId: string }>();
|
|
848
|
+
```
|
|
849
|
+
|
|
560
850
|
### How DynamicRouter resolves routes
|
|
561
851
|
|
|
562
852
|
1. Fetches menu from `GET /api/navigation/menu`
|
|
@@ -618,11 +908,13 @@ These patterns are **strictly prohibited** in generated frontend code:
|
|
|
618
908
|
| Check | Status |
|
|
619
909
|
|-------|--------|
|
|
620
910
|
| ☐ Main page created (`$MODULE_PASCALPage.tsx`) | |
|
|
911
|
+
| ☐ Detail page created (`$MODULE_PASCALDetailPage.tsx`) with `useParams<{ id: string }>` | |
|
|
912
|
+
| ☐ Edit page created (`$MODULE_PASCALEditPage.tsx`) with `useParams<{ id: string }>` | |
|
|
621
913
|
| ☐ ListView component created (`$MODULE_PASCALListView.tsx`) | |
|
|
622
914
|
| ☐ Preferences hook created (`use$MODULE_PASCALPreferences.ts`) | |
|
|
623
915
|
| ☐ API service created (uses `apiClient`, NOT raw axios) | |
|
|
624
|
-
| ☐
|
|
625
|
-
| ☐ Route
|
|
916
|
+
| ☐ PageRegistry keys registered (`.detail`, `.create`, `.edit`) in `componentRegistry.generated.ts` | |
|
|
917
|
+
| ☐ Route param uses `{ id: entityId }` destructuring (NOT `{ entityId }`) | |
|
|
626
918
|
|
|
627
919
|
### Theme Compliance
|
|
628
920
|
| Check | Status |
|