@simplysm/sd-cli 14.0.95 → 14.0.97

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 (99) hide show
  1. package/dist/commands/init/generators/client.d.ts.map +1 -1
  2. package/dist/commands/init/generators/client.js +12 -0
  3. package/dist/commands/init/generators/client.js.map +1 -1
  4. package/dist/commands/init/generators/server.d.ts.map +1 -1
  5. package/dist/commands/init/generators/server.js +1 -0
  6. package/dist/commands/init/generators/server.js.map +1 -1
  7. package/dist/commands/init/normalize.d.ts.map +1 -1
  8. package/dist/commands/init/normalize.js +1 -0
  9. package/dist/commands/init/normalize.js.map +1 -1
  10. package/dist/commands/init/prompts.d.ts.map +1 -1
  11. package/dist/commands/init/prompts.js +8 -1
  12. package/dist/commands/init/prompts.js.map +1 -1
  13. package/dist/commands/init/types.d.ts +3 -0
  14. package/dist/commands/init/types.d.ts.map +1 -1
  15. package/dist/engines/EsbuildClientEngine.d.ts.map +1 -1
  16. package/dist/engines/EsbuildClientEngine.js +1 -0
  17. package/dist/engines/EsbuildClientEngine.js.map +1 -1
  18. package/dist/esbuild/esbuild-client-config.d.ts.map +1 -1
  19. package/dist/esbuild/esbuild-client-config.js +2 -11
  20. package/dist/esbuild/esbuild-client-config.js.map +1 -1
  21. package/dist/esbuild/esbuild-postcss-plugin.d.ts +4 -0
  22. package/dist/esbuild/esbuild-postcss-plugin.d.ts.map +1 -1
  23. package/dist/esbuild/esbuild-postcss-plugin.js +15 -0
  24. package/dist/esbuild/esbuild-postcss-plugin.js.map +1 -1
  25. package/dist/esbuild/esbuild-ssr-config.d.ts +27 -0
  26. package/dist/esbuild/esbuild-ssr-config.d.ts.map +1 -0
  27. package/dist/esbuild/esbuild-ssr-config.js +113 -0
  28. package/dist/esbuild/esbuild-ssr-config.js.map +1 -0
  29. package/dist/sd-config.types.d.ts +7 -0
  30. package/dist/sd-config.types.d.ts.map +1 -1
  31. package/dist/ssg/prerender.d.ts +19 -0
  32. package/dist/ssg/prerender.d.ts.map +1 -0
  33. package/dist/ssg/prerender.js +43 -0
  34. package/dist/ssg/prerender.js.map +1 -0
  35. package/dist/workers/client.worker.d.ts +2 -0
  36. package/dist/workers/client.worker.d.ts.map +1 -1
  37. package/dist/workers/client.worker.js +21 -0
  38. package/dist/workers/client.worker.js.map +1 -1
  39. package/package.json +6 -6
  40. package/src/commands/init/generators/client.ts +53 -0
  41. package/src/commands/init/generators/server.ts +5 -0
  42. package/src/commands/init/normalize.ts +1 -0
  43. package/src/commands/init/prompts.ts +9 -1
  44. package/src/commands/init/templates/client/package.json.hbs +2 -1
  45. package/src/commands/init/templates/client/src/app/home/home.view.ts.hbs +1 -1
  46. package/src/commands/init/templates/client/src/app/home/master/role-permission/role-permission.detail.ts.hbs +221 -0
  47. package/src/commands/init/templates/client/src/app/home/master/role-permission/role-permission.view.ts.hbs +106 -0
  48. package/src/commands/init/templates/client/src/app/home/master/role-permission/role.detail.ts.hbs +277 -0
  49. package/src/commands/init/templates/client/src/app/home/master/role-permission/role.list.ts.hbs +537 -0
  50. package/src/commands/init/templates/client/src/app/home/master/user.detail.ts.hbs +337 -0
  51. package/src/commands/init/templates/client/src/app/home/master/user.list.ts.hbs +540 -0
  52. package/src/commands/init/templates/client/src/app/home/my-info/my-info.detail.ts.hbs +4 -6
  53. package/src/commands/init/templates/client/src/app/home/system/data-log/data-log.list.ts.hbs +355 -0
  54. package/src/commands/init/templates/client/src/app/home/system/system-log/system-log.list.ts.hbs +382 -0
  55. package/src/commands/init/templates/client/src/app/login/login.view.ts.hbs +3 -4
  56. package/src/commands/init/templates/client/src/app.root.ts.hbs +9 -3
  57. package/src/commands/init/templates/client/src/index.html.hbs +1 -0
  58. package/src/commands/init/templates/client/src/main.server.ts.hbs +24 -0
  59. package/src/commands/init/templates/client/src/main.ts.hbs +36 -18
  60. package/src/commands/init/templates/client/src/modals/text-view.modal.ts.hbs +30 -0
  61. package/src/commands/init/templates/client/src/routes.ts.hbs +22 -0
  62. package/src/commands/init/templates/client-common/src/index.ts.hbs +6 -4
  63. package/src/commands/init/templates/client-common/src/providers/app-auth.provider.ts.hbs +3 -3
  64. package/src/commands/init/templates/client-common/src/providers/app-orm.provider.ts.hbs +0 -11
  65. package/src/commands/init/templates/client-common/src/providers/app-service.provider.ts.hbs +24 -10
  66. package/src/commands/init/templates/client-common/src/providers/app-shared-data.provider.ts.hbs +2 -2
  67. package/src/commands/init/templates/common/package.json.hbs +2 -1
  68. package/src/commands/init/templates/common/src/app-structure.ts.hbs +7 -2
  69. package/src/commands/init/templates/common/src/auth-info-changed.event.ts.hbs +3 -1
  70. package/src/commands/init/templates/common/src/db/db-context.ts.hbs +2 -2
  71. package/src/commands/init/templates/common/src/db/tables/master/user.ts.hbs +2 -2
  72. package/src/commands/init/templates/common/src/db/tables/system/role.ts.hbs +1 -0
  73. package/src/commands/init/templates/common/src/index.ts.hbs +1 -1
  74. package/src/commands/init/templates/server/src/index.ts.hbs +3 -0
  75. package/src/commands/init/templates/server/src/main.ts.hbs +15 -1
  76. package/src/commands/init/templates/server/src/services/auth.service.ts.hbs +28 -22
  77. package/src/commands/init/templates/server/src/services/dev.service.ts.hbs +5 -5
  78. package/src/commands/init/templates/server/src/services/user.service.ts.hbs +191 -0
  79. package/src/commands/init/templates/workspace-root/sd.config.ts.hbs +3 -0
  80. package/src/commands/init/types.ts +3 -0
  81. package/src/engines/EsbuildClientEngine.ts +1 -0
  82. package/src/esbuild/esbuild-client-config.ts +2 -12
  83. package/src/esbuild/esbuild-postcss-plugin.ts +18 -0
  84. package/src/esbuild/esbuild-ssr-config.ts +149 -0
  85. package/src/sd-config.types.ts +7 -0
  86. package/src/ssg/prerender.ts +65 -0
  87. package/src/workers/client.worker.ts +26 -0
  88. package/tests/engines/base-engine.spec.ts +1 -26
  89. package/tests/init/__snapshots__/render.spec.ts.snap +38 -20
  90. package/tests/init/render.spec.ts +113 -33
  91. package/tests/utils/hmr-client-script.acc.spec.ts +0 -21
  92. package/tests/angular/vite-angular-plugin.spec.ts +0 -102
  93. package/tests/engines/engine-adapter-isolation.spec.ts +0 -79
  94. package/tests/runtime/signal-handler.spec.ts +0 -21
  95. package/tests/utils/angular-build.spec.ts +0 -109
  96. package/tests/utils/esbuild-client-config.acc.spec.ts +0 -438
  97. package/tests/utils/esbuild-client-config.spec.ts +0 -659
  98. package/tests/utils/hmr-client-script.spec.ts +0 -44
  99. package/tests/utils/tsconfig-angular.spec.ts +0 -9
@@ -2,20 +2,25 @@ import { inject, Injectable } from "@angular/core";
2
2
  import { SdServiceClientFactoryProvider } from "@simplysm/angular";
3
3
  import { env, num, parseBoolEnv } from "@simplysm/core-common";
4
4
  {{#if hasDb}}
5
+ import type {
6
+ {{#if hasAuth}}
7
+ AuthServiceMethods,
8
+ {{/if}}
9
+ DevServiceMethods,
10
+ {{#if hasAuth}}
11
+ {{userEntityPascal}}ServiceMethods,
12
+ {{/if}}
13
+ } from "@{{workspaceName}}/server";
5
14
  import {
6
- createOrmClientConnector,
7
- type OrmClientConnector,
8
- type ServiceProxy,
9
15
  {{#if hasAuth}}
10
16
  type ClientEventProxy,
11
17
  {{/if}}
18
+ createOrmClientConnector,
19
+ type OrmClientConnector,
20
+ type ServiceProxy,
12
21
  } from "@simplysm/service-client";
13
22
  {{/if}}
14
- {{#if hasDb}}
15
- import type { DevServiceMethods } from "@{{workspaceName}}/server";
16
- {{/if}}
17
23
  {{#if hasAuth}}
18
- import type { AuthServiceMethods } from "@{{workspaceName}}/server";
19
24
  import { AuthInfoChangedEvent } from "@{{workspaceName}}/common";
20
25
  {{/if}}
21
26
 
@@ -34,6 +39,15 @@ export class AppServiceProvider {
34
39
  get orm(): OrmClientConnector {
35
40
  return (this._orm ??= createOrmClientConnector(this.client));
36
41
  }
42
+ {{/if}}
43
+ {{#if hasAuth}}
44
+
45
+ private _auth?: ServiceProxy<AuthServiceMethods>;
46
+ get auth(): ServiceProxy<AuthServiceMethods> {
47
+ return (this._auth ??= this.client.getService<AuthServiceMethods>("AuthService"));
48
+ }
49
+ {{/if}}
50
+ {{#if hasDb}}
37
51
 
38
52
  private _dev?: ServiceProxy<DevServiceMethods>;
39
53
  get dev(): ServiceProxy<DevServiceMethods> {
@@ -42,9 +56,9 @@ export class AppServiceProvider {
42
56
  {{/if}}
43
57
  {{#if hasAuth}}
44
58
 
45
- private _auth?: ServiceProxy<AuthServiceMethods>;
46
- get auth(): ServiceProxy<AuthServiceMethods> {
47
- return (this._auth ??= this.client.getService<AuthServiceMethods>("AuthService"));
59
+ private _{{userEntityCamel}}?: ServiceProxy<{{userEntityPascal}}ServiceMethods>;
60
+ get {{userEntityCamel}}(): ServiceProxy<{{userEntityPascal}}ServiceMethods> {
61
+ return (this._{{userEntityCamel}} ??= this.client.getService<{{userEntityPascal}}ServiceMethods>("{{userEntityPascal}}Service"));
48
62
  }
49
63
 
50
64
  private _authInfoChangedEvent?: ClientEventProxy<typeof AuthInfoChangedEvent>;
@@ -12,10 +12,10 @@ import { AppOrmProvider } from "./app-orm.provider";
12
12
  {{/if}}
13
13
 
14
14
  export function useSharedSignal<K extends keyof TAppSharedData>(
15
- name: K,
15
+ dataKey: K,
16
16
  ): SharedDataHandle<TAppSharedData[K]> {
17
17
  const appSharedData = inject(AppSharedDataProvider);
18
- return appSharedData.getHandle(name);
18
+ return appSharedData.getHandle(dataKey);
19
19
  }
20
20
 
21
21
  @Injectable({ providedIn: "root" })
@@ -6,6 +6,7 @@
6
6
  "dependencies": {
7
7
  "@simplysm/core-common": "^14.0.0"{{#if hasDb}},
8
8
  "@simplysm/orm-common": "^14.0.0"{{/if}}{{#if hasAuth}},
9
- "@simplysm/service-common": "^14.0.0"{{/if}}
9
+ "@simplysm/service-common": "^14.0.0",
10
+ "@ng-icons/tabler-icons": "^33.2.3"{{/if}}
10
11
  }
11
12
  }
@@ -1,13 +1,18 @@
1
1
  import type { AppStructureItem } from "@simplysm/service-common";
2
+ import { tablerDatabase, tablerSettings } from "@ng-icons/tabler-icons";
2
3
 
3
4
  {{#each appStructureNames}}
5
+ {{#unless @first}}
6
+
7
+ {{/unless}}
4
8
  export const {{this}}: AppStructureItem[] = [
5
9
  { title: "메인화면", code: "main", isNotMenu: true },
6
10
  { title: "내 정보 수정", code: "my-info", isNotMenu: true },
7
11
 
8
12
  {
9
- code: "base",
13
+ code: "master",
10
14
  title: "기준정보",
15
+ icon: tablerDatabase,
11
16
  children: [
12
17
  { code: "role-permission", title: "역할/권한", perms: ["use", "edit"] },
13
18
  { code: "{{../userEntityKebab}}", title: "{{../userEntityLabel}}", perms: ["use", "edit"] },
@@ -16,11 +21,11 @@ export const {{this}}: AppStructureItem[] = [
16
21
  {
17
22
  code: "system",
18
23
  title: "시스템",
24
+ icon: tablerSettings,
19
25
  children: [
20
26
  { code: "data-log", title: "데이터 변경내역", perms: ["use"] },
21
27
  { code: "system-log", title: "시스템 로그", perms: ["use"] },
22
28
  ],
23
29
  },
24
30
  ];
25
-
26
31
  {{/each}}
@@ -1,3 +1,5 @@
1
1
  import { defineEvent } from "@simplysm/service-common";
2
2
 
3
- export const AuthInfoChangedEvent = defineEvent<{ {{userEntityCamel}}Id: number }, void>("AuthInfoChanged");
3
+ export const AuthInfoChangedEvent = defineEvent<{ {{userEntityCamel}}Id: number; roleId: number }, void>(
4
+ "AuthInfoChanged",
5
+ );
@@ -1,8 +1,8 @@
1
1
  import { DbContext } from "@simplysm/orm-common";
2
2
  {{#if hasAuth}}
3
3
  import { {{userEntityPascal}} } from "./tables/master/{{userEntityKebab}}";
4
- import { {{userEntityPascal}}Config } from "./tables/master/{{userEntityKebab}}-config";
5
4
  import { Role } from "./tables/system/role";
5
+ import { {{userEntityPascal}}Config } from "./tables/master/{{userEntityKebab}}-config";
6
6
  import { RolePermission } from "./tables/system/role-permission";
7
7
  {{/if}}
8
8
  import { SystemDataLog } from "./tables/system/system-data-log";
@@ -11,8 +11,8 @@ import { SystemLog } from "./tables/system/system-log";
11
11
  export class {{dbContextClassName}} extends DbContext {
12
12
  {{#if hasAuth}}
13
13
  {{userEntityCamel}} = this.queryable({{userEntityPascal}});
14
- {{userEntityCamel}}Config = this.queryable({{userEntityPascal}}Config);
15
14
  role = this.queryable(Role);
15
+ {{userEntityCamel}}Config = this.queryable({{userEntityPascal}}Config);
16
16
  rolePermission = this.queryable(RolePermission);
17
17
  {{/if}}
18
18
  dataLog = this.queryable(SystemDataLog);
@@ -7,13 +7,13 @@ export const {{userEntityPascal}} = Table("{{userEntityPascal}}")
7
7
  .columns((c) => ({
8
8
  id: c.bigint().autoIncrement(),
9
9
  name: c.varchar(100),
10
- loginId: c.varchar(50),
10
+ loginId: c.varchar(50).nullable(),
11
11
  encryptedPassword: c.varchar(200).nullable(),
12
12
  roleId: c.bigint().nullable(),
13
13
  isDeleted: c.boolean(),
14
14
  }))
15
15
  .primaryKey("id")
16
- .indexes((i) => [i.index("loginId").unique()])
16
+ .indexes((i) => [i.index("loginId").unique(), i.index("name", "isDeleted")])
17
17
  .relations((r) => ({
18
18
  role: r.foreignKey(["roleId"], () => Role),
19
19
  configs: r.foreignKeyTarget(() => {{userEntityPascal}}Config, "{{userEntityCamel}}"),
@@ -10,6 +10,7 @@ export const Role = Table("Role")
10
10
  isDeleted: c.boolean(),
11
11
  }))
12
12
  .primaryKey("id")
13
+ .indexes((i) => [i.index("name", "isDeleted")])
13
14
  .relations((r) => ({
14
15
  {{userEntityCamel}}s: r.foreignKeyTarget(() => {{userEntityPascal}}, "role"),
15
16
  permissions: r.foreignKeyTarget(() => RolePermission, "role"),
@@ -2,8 +2,8 @@
2
2
  export * from "./{{dbFolderName}}/{{dbContextFileName}}";
3
3
  {{#if hasAuth}}
4
4
  export * from "./{{dbFolderName}}/tables/master/{{userEntityKebab}}";
5
- export * from "./{{dbFolderName}}/tables/master/{{userEntityKebab}}-config";
6
5
  export * from "./{{dbFolderName}}/tables/system/role";
6
+ export * from "./{{dbFolderName}}/tables/master/{{userEntityKebab}}-config";
7
7
  export * from "./{{dbFolderName}}/tables/system/role-permission";
8
8
  {{/if}}
9
9
  export * from "./{{dbFolderName}}/tables/system/system-data-log";
@@ -2,3 +2,6 @@
2
2
  export type * from "./services/auth.service";
3
3
  {{/if}}
4
4
  export type * from "./services/dev.service";
5
+ {{#if hasAuth}}
6
+ export type * from "./services/{{userEntityKebab}}.service";
7
+ {{/if}}
@@ -9,6 +9,9 @@ import { AuthService } from "./services/auth.service";
9
9
  {{#if hasDb}}
10
10
  import { DevService } from "./services/dev.service";
11
11
  {{/if}}
12
+ {{#if hasAuth}}
13
+ import { {{userEntityPascal}}Service } from "./services/{{userEntityKebab}}.service";
14
+ {{/if}}
12
15
 
13
16
  Error.stackTraceLimit = Infinity;
14
17
  EventEmitter.defaultMaxListeners = 0;
@@ -25,7 +28,18 @@ if (authConfig?.jwtSecret == null) {
25
28
 
26
29
  export const server = createServiceServer({
27
30
  rootPath: import.meta.dirname,
28
- services: [{{#if hasDb}}OrmService{{#if hasAuth}}, AuthService{{/if}}, ...(parseBoolEnv(env("DEV")) ? [DevService] : []){{/if}}],
31
+ services: [
32
+ {{#if hasDb}}
33
+ OrmService,
34
+ {{/if}}
35
+ {{#if hasAuth}}
36
+ AuthService,
37
+ {{userEntityPascal}}Service,
38
+ {{/if}}
39
+ {{#if hasDb}}
40
+ ...(parseBoolEnv(env("DEV")) ? [DevService] : []),
41
+ {{/if}}
42
+ ],
29
43
  port: num.parseInt(env("SERVER_PORT"))!,
30
44
  auth: { jwtSecret: authConfig.jwtSecret },
31
45
  });
@@ -10,6 +10,7 @@ export interface I{{userEntityPascal}}ConfigMap {
10
10
 
11
11
  export interface IAuthData {
12
12
  {{userEntityCamel}}Id: number;
13
+ roleId: number;
13
14
  name: string;
14
15
  roleName: string;
15
16
  loginId: string;
@@ -25,26 +26,33 @@ export const AuthService = defineService("AuthService", (ctx) => {
25
26
  async function buildAuthResult({{userEntityCamel}}: {
26
27
  id: number;
27
28
  name: string;
28
- loginId: string;
29
+ loginId?: string;
29
30
  role?: { id: number; name: string };
30
31
  permissions?: { code: string; valueJson: string }[];
31
32
  configs?: { code: string; valueJson: string }[];
32
33
  }): Promise<IAuthResult> {
34
+ // 인증된 사용자는 loginId 가 반드시 존재(로그인으로 식별됨). 없으면 인증 불가 상태이므로 throw.
35
+ if ({{userEntityCamel}}.loginId == null) {
36
+ throw new Error("로그인 ID가 없는 계정입니다.");
37
+ }
38
+ const loginId = {{userEntityCamel}}.loginId;
39
+
33
40
  const permissions: Record<string, boolean> = {};
34
41
  for (const rp of {{userEntityCamel}}.permissions ?? []) {
35
42
  permissions[rp.code] = JSON.parse(rp.valueJson) as boolean;
36
43
  }
37
44
 
38
45
  const configs: I{{userEntityPascal}}ConfigMap = {};
39
- for (const cfg of {{userEntityCamel}}.configs ?? []) {
40
- (configs as Record<string, unknown>)[cfg.code] = JSON.parse(cfg.valueJson);
46
+ for (const c of {{userEntityCamel}}.configs ?? []) {
47
+ (configs as Record<string, unknown>)[c.code] = JSON.parse(c.valueJson);
41
48
  }
42
49
 
43
50
  const authData: IAuthData = {
44
51
  {{userEntityCamel}}Id: {{userEntityCamel}}.id,
52
+ roleId: {{userEntityCamel}}.role!.id,
45
53
  name: {{userEntityCamel}}.name,
46
54
  roleName: {{userEntityCamel}}.role!.name,
47
- loginId: {{userEntityCamel}}.loginId,
55
+ loginId,
48
56
  permissions,
49
57
  configs,
50
58
  };
@@ -72,6 +80,7 @@ export const AuthService = defineService("AuthService", (ctx) => {
72
80
  const {{userEntityCamel}} = await db
73
81
  .{{userEntityCamel}}()
74
82
  .where((c) => [expr.eq(c.loginId, loginId)])
83
+ .include((item) => item.role)
75
84
  .join("permissions", (qr, e) =>
76
85
  qr
77
86
  .from(RolePermission)
@@ -81,10 +90,9 @@ export const AuthService = defineService("AuthService", (ctx) => {
81
90
  .join("configs", (qr, e) =>
82
91
  qr
83
92
  .from({{userEntityPascal}}Config)
84
- .where((cfg) => [expr.eq(cfg.{{userEntityCamel}}Id, e.id)])
85
- .select((cfg) => ({ code: cfg.code, valueJson: cfg.valueJson })),
93
+ .where((ec) => [expr.eq(ec.{{userEntityCamel}}Id, e.id)])
94
+ .select((ec) => ({ code: ec.code, valueJson: ec.valueJson })),
86
95
  )
87
- .include((item) => item.role)
88
96
  .select((item) => ({
89
97
  id: item.id,
90
98
  name: item.name,
@@ -133,6 +141,7 @@ export const AuthService = defineService("AuthService", (ctx) => {
133
141
  return orm.connect(async (db) => {
134
142
  const {{userEntityCamel}} = await db
135
143
  .{{userEntityCamel}}()
144
+ .include((item) => item.role)
136
145
  .where((c) => [expr.eq(c.id, currentAuth.{{userEntityCamel}}Id)])
137
146
  .join("permissions", (qr, e) =>
138
147
  qr
@@ -143,10 +152,9 @@ export const AuthService = defineService("AuthService", (ctx) => {
143
152
  .join("configs", (qr, e) =>
144
153
  qr
145
154
  .from({{userEntityPascal}}Config)
146
- .where((cfg) => [expr.eq(cfg.{{userEntityCamel}}Id, e.id)])
147
- .select((cfg) => ({ code: cfg.code, valueJson: cfg.valueJson })),
155
+ .where((ec) => [expr.eq(ec.{{userEntityCamel}}Id, e.id)])
156
+ .select((ec) => ({ code: ec.code, valueJson: ec.valueJson })),
148
157
  )
149
- .include((item) => item.role)
150
158
  .select((item) => ({
151
159
  id: item.id,
152
160
  name: item.name,
@@ -234,18 +242,16 @@ export const AuthService = defineService("AuthService", (ctx) => {
234
242
  const codes = Object.keys(input.configs).filter(
235
243
  (code) => (input.configs as Record<string, unknown>)[code] != null,
236
244
  );
237
- const existingConfigs = await db
238
- .{{userEntityCamel}}Config()
239
- .where((c) => [expr.eq(c.{{userEntityCamel}}Id, {{userEntityCamel}}Id)])
240
- .select((c) => ({ code: c.code }))
241
- .execute();
242
- for (const existing of existingConfigs) {
243
- if (!codes.includes(existing.code)) {
244
- await db
245
- .{{userEntityCamel}}Config()
246
- .where((c) => [expr.eq(c.{{userEntityCamel}}Id, {{userEntityCamel}}Id), expr.eq(c.code, existing.code)])
247
- .delete();
248
- }
245
+ if (codes.length === 0) {
246
+ await db
247
+ .{{userEntityCamel}}Config()
248
+ .where((c) => [expr.eq(c.{{userEntityCamel}}Id, {{userEntityCamel}}Id)])
249
+ .delete();
250
+ } else {
251
+ await db
252
+ .{{userEntityCamel}}Config()
253
+ .where((c) => [expr.eq(c.{{userEntityCamel}}Id, {{userEntityCamel}}Id), expr.not(expr.in(c.code, codes))])
254
+ .delete();
249
255
  }
250
256
  for (const code of codes) {
251
257
  const valueJson = JSON.stringify((input.configs as Record<string, unknown>)[code]);
@@ -78,11 +78,13 @@ export const DevService = defineService("DevService", (ctx) => {
78
78
 
79
79
  if ("역할권한" in excelData) {
80
80
  const allPermCodes = getFlatPermissions(
81
+ {{#if appStructureNames.[1]}}
81
82
  [{{#each appStructureNames}}...{{this}}{{#unless @last}}, {{/unless}}{{/each}}],
83
+ {{else}}
84
+ {{appStructureNames.[0]}},
85
+ {{/if}}
82
86
  undefined,
83
- )
84
- .map((p) => p.codeChain.join("."))
85
- .distinct();
87
+ ).map((p) => p.codeChain.join("."));
86
88
  await db.rolePermission().insert(
87
89
  excelData["역할권한"]
88
90
  .filter((r) => r["역할.ID"] != null)
@@ -98,8 +100,6 @@ export const DevService = defineService("DevService", (ctx) => {
98
100
  );
99
101
  await db.rolePermission().insertDataLog({ action: "초기화" });
100
102
  }
101
-
102
- // TODO: 업무 테이블 초기 데이터 시드 추가
103
103
  });
104
104
  {{else}}
105
105
  // TODO: 초기 데이터 시드 추가
@@ -0,0 +1,191 @@
1
+ import { auth, defineService, type ServiceMethods } from "@simplysm/service-server";
2
+ import { expr } from "@simplysm/orm-common";
3
+ import bcrypt from "bcrypt";
4
+ import { getOrm } from "../utils/orm.utils";
5
+ import type { IAuthData } from "./auth.service";
6
+
7
+ export interface I{{userEntityPascal}}SaveInput {
8
+ id?: number;
9
+ name: string;
10
+ loginId?: string;
11
+ roleId?: number;
12
+ password?: string;
13
+ isDeleted?: boolean;
14
+ }
15
+
16
+ export const {{userEntityPascal}}Service = defineService("{{userEntityPascal}}Service", (ctx) => {
17
+ return {
18
+ // {{userEntityLabel}} 등록/수정 일괄 처리. 비밀번호는 bcrypt 해시(브라우저 실행 불가 + 보안)라 서버에서 처리.
19
+ // 전체를 한 트랜잭션으로 처리 — 한 건이라도 실패하면 전부 롤백(원자성).
20
+ save: auth(async (inputs: I{{userEntityPascal}}SaveInput[]): Promise<{ id: number }[]> => {
21
+ const currentAuth = ctx.authInfo as IAuthData | undefined;
22
+ if (!currentAuth) {
23
+ throw new Error("인증 정보가 없습니다.");
24
+ }
25
+ const performerId = currentAuth.{{userEntityCamel}}Id;
26
+
27
+ // 에러 식별 접두사 — 단건(상세 화면)이면 생략, 다건(엑셀 업로드)이면 이름으로 표기.
28
+ const label = ({{userEntityCamel}}Name: string): string =>
29
+ inputs.length > 1 ? `{{userEntityLabel}} '${ {{~userEntityCamel}}Name}': ` : "";
30
+
31
+ // 행별 형식 검증 + 정규화 (DB 접근 전).
32
+ const rows = inputs.map((input) => {
33
+ const {{userEntityCamel}}Name = input.name.trim();
34
+ if ({{userEntityCamel}}Name === "" || {{userEntityCamel}}Name.length > 100) {
35
+ throw new Error(`${label({{userEntityCamel}}Name)}이름은 1~100자의 문자열이어야 합니다.`);
36
+ }
37
+
38
+ // 로그인ID 는 선택값(로그인 안 하는 {{userEntityLabel}}). 빈 문자열은 NULL 로 정규화.
39
+ const trimmedLoginId = input.loginId?.trim();
40
+ const loginId =
41
+ trimmedLoginId != null && trimmedLoginId !== "" ? trimmedLoginId : undefined;
42
+ if (loginId != null && loginId.length > 50) {
43
+ throw new Error(`${label({{userEntityCamel}}Name)}로그인ID는 50자 이하의 문자열이어야 합니다.`);
44
+ }
45
+
46
+ const password =
47
+ input.password != null && input.password !== "" ? input.password : undefined;
48
+ if (password != null && password.length > 500) {
49
+ throw new Error(`${label({{userEntityCamel}}Name)}비밀번호는 1~500자의 문자열이어야 합니다.`);
50
+ }
51
+
52
+ return {
53
+ id: input.id,
54
+ name: {{userEntityCamel}}Name,
55
+ loginId,
56
+ roleId: input.roleId,
57
+ password,
58
+ isDeleted: input.isDeleted,
59
+ };
60
+ });
61
+
62
+ // 배치 내 로그인ID 중복 검증 — 같은 업로드 안의 충돌은 DB 검사로 못 잡으므로 여기서 차단.
63
+ // (이름 활성유니크는 행 반영 후 한 번에 후검증 — 아래 참조.)
64
+ const findDuplicate = (values: (string | undefined)[]): string | undefined => {
65
+ const seen = new Set<string>();
66
+ for (const value of values) {
67
+ if (value == null) continue;
68
+ if (seen.has(value)) return value;
69
+ seen.add(value);
70
+ }
71
+ return undefined;
72
+ };
73
+ const dupLoginId = findDuplicate(rows.map((r) => r.loginId));
74
+ if (dupLoginId != null) {
75
+ throw new Error(`업로드 내 로그인ID가 중복됩니다: '${dupLoginId}'`);
76
+ }
77
+
78
+ const orm = await getOrm(ctx);
79
+ return orm.connect(async (db) => {
80
+ const results: { id: number }[] = [];
81
+ const activeNames: string[] = [];
82
+
83
+ for (const { id, name, loginId, roleId, password, isDeleted } of rows) {
84
+ // 수정 대상이면 기존 레코드 확인 — id 존재 강제 + 불변식·이력전이 판정에 기존 상태 필요.
85
+ let existingEncryptedPassword: string | undefined;
86
+ let existingIsDeleted: boolean | undefined;
87
+ if (id != null) {
88
+ const existing = await db
89
+ .{{userEntityCamel}}()
90
+ .where((c) => [expr.eq(c.id, id)])
91
+ .select((c) => ({
92
+ encryptedPassword: c.encryptedPassword,
93
+ isDeleted: c.isDeleted,
94
+ }))
95
+ .single();
96
+ if (existing == null) {
97
+ throw new Error(`${label(name)}존재하지 않는 {{userEntityLabel}}입니다. (ID: ${id})`);
98
+ }
99
+ existingEncryptedPassword = existing.encryptedPassword ?? undefined;
100
+ existingIsDeleted = existing.isDeleted;
101
+ }
102
+
103
+ // 저장 후 삭제상태 — 입력값 우선, 없으면(상세 화면) 기존 유지, 신규는 활성(false).
104
+ const resultIsDeleted = isDeleted ?? existingIsDeleted ?? false;
105
+ // 활성으로 귀결되는 이름은 후검증(활성유니크) 대상으로 수집.
106
+ if (!resultIsDeleted) {
107
+ activeNames.push(name);
108
+ }
109
+
110
+ // 불변식: 로그인ID 가 있으면 역할·비밀번호 필수 (비번은 이번 입력 또는 수정 시 기존 해시로 충족).
111
+ if (loginId != null) {
112
+ if (roleId == null) {
113
+ throw new Error(`${label(name)}로그인ID가 있으면 역할이 필수입니다.`);
114
+ }
115
+ if (password == null && existingEncryptedPassword == null) {
116
+ throw new Error(`${label(name)}로그인ID가 있으면 비밀번호가 필수입니다.`);
117
+ }
118
+ }
119
+
120
+ // 로그인ID 중복검증 (자기 자신 제외). loginId 는 DB 유니크 인덱스라 삭제 건도 포함해 검사.
121
+ if (loginId != null) {
122
+ const isDuplicated = await db
123
+ .{{userEntityCamel}}()
124
+ .where((c) => [
125
+ expr.eq(c.loginId, loginId),
126
+ ...(id == null ? [] : [expr.not(expr.eq(c.id, id))]),
127
+ ])
128
+ .exists();
129
+ if (isDuplicated) {
130
+ throw new Error(`${label(name)}이미 사용 중인 로그인ID입니다.`);
131
+ }
132
+ }
133
+
134
+ const encryptedPassword =
135
+ password != null ? await bcrypt.hash(password, 10) : undefined;
136
+
137
+ if (id == null) {
138
+ const [inserted] = await db
139
+ .{{userEntityCamel}}()
140
+ .insert([{ name, loginId, encryptedPassword, roleId, isDeleted: resultIsDeleted }], [
141
+ "id",
142
+ ]);
143
+ await db.{{userEntityCamel}}().insertDataLog({
144
+ action: "등록",
145
+ itemId: inserted.id,
146
+ {{userEntityCamel}}Id: performerId,
147
+ });
148
+ results.push({ id: inserted.id });
149
+ } else {
150
+ // 비밀번호 미입력 시 기존 해시 유지 — encryptedPassword 키를 set 에서 제외.
151
+ await db
152
+ .{{userEntityCamel}}()
153
+ .where((c) => [expr.eq(c.id, id)])
154
+ .update(() => ({
155
+ name,
156
+ loginId,
157
+ roleId,
158
+ isDeleted: resultIsDeleted,
159
+ ...(encryptedPassword == null ? {} : { encryptedPassword }),
160
+ }));
161
+ // 삭제상태 전이에 따른 이력 action.
162
+ const action =
163
+ existingIsDeleted === resultIsDeleted ? "수정" : resultIsDeleted ? "삭제" : "복구";
164
+ await db.{{userEntityCamel}}().insertDataLog({ action, itemId: id, {{userEntityCamel}}Id: performerId });
165
+ results.push({ id });
166
+ }
167
+ }
168
+
169
+ // 이름 활성유니크 후검증 — 모든 행 반영 후, 활성으로 귀결된 이름 중 활성 중복이 있으면 throw(전체 롤백).
170
+ // 벌크는 처리 전 상태로는 대상끼리 충돌을 못 보므로 후검증. 삭제 행은 제외돼 동명이 허용된다.
171
+ if (activeNames.length > 0) {
172
+ const conflicts = await db
173
+ .{{userEntityCamel}}()
174
+ .where((c) => [expr.in(c.name, activeNames), expr.eq(c.isDeleted, false)])
175
+ .groupBy((c) => [c.name])
176
+ .having(() => [expr.gt(expr.count(), 1)])
177
+ .select((c) => ({ name: c.name }))
178
+ .execute();
179
+ if (conflicts.length > 0) {
180
+ const conflictNames = conflicts.map((x) => `'${x.name}'`).join(", ");
181
+ throw new Error(`이미 존재하는 {{userEntityLabel}} 이름: ${conflictNames}`);
182
+ }
183
+ }
184
+
185
+ return results;
186
+ });
187
+ }),
188
+ };
189
+ });
190
+
191
+ export type {{userEntityPascal}}ServiceMethods = ServiceMethods<typeof {{userEntityPascal}}Service>;
@@ -44,6 +44,9 @@ const config: SdConfigFn = () => ({
44
44
  {{#if ../hasServer}}
45
45
  server: "server",
46
46
  {{/if}}
47
+ {{#if useSsg}}
48
+ prerender: ["/"],
49
+ {{/if}}
47
50
  {{#if isMobile}}
48
51
  pwa: false,
49
52
  capacitor: {
@@ -5,6 +5,8 @@ export interface ClientInputSpec {
5
5
  name: string;
6
6
  type: ClientType;
7
7
  hasRouter: boolean;
8
+ /** SSG(빌드 타임 프리렌더) 사용 여부 (web + 라우팅 클라이언트만) */
9
+ useSsg?: boolean;
8
10
  }
9
11
 
10
12
  export interface InitInput {
@@ -29,6 +31,7 @@ export interface ClientSpec {
29
31
  isMobile: boolean;
30
32
  appStructureName: string;
31
33
  needsNgIcons: boolean;
34
+ useSsg: boolean;
32
35
  }
33
36
 
34
37
  export interface NormalizedInput {
@@ -72,6 +72,7 @@ export class EsbuildClientEngine implements BuildEngine {
72
72
  outDir: this._outDir,
73
73
  base: this._base,
74
74
  browserSupport: this._pkg.config.browserSupport,
75
+ prerender: this._pkg.config.prerender,
75
76
  });
76
77
 
77
78
  logger.debug(`[${this._pkg.name}] EsbuildClientEngine.run 완료 (success: ${result.success})`);
@@ -1,9 +1,7 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- import { createRequire } from "module";
4
3
  import esbuild from "esbuild";
5
4
  import browserslistToEsbuild from "browserslist-to-esbuild";
6
- import type { AcceptedPlugin } from "postcss";
7
5
  import { AngularSourceFileCache } from "../angular/angular-compiler.js";
8
6
  import { createClientTransformStylesheet } from "../angular/client-transform-stylesheet.js";
9
7
  import {
@@ -12,7 +10,7 @@ import {
12
10
  } from "./esbuild-angular-compiler-plugin.js";
13
11
  import { MemoryLoadResultCache } from "./load-result-cache.js";
14
12
  import { createScssPlugin } from "./esbuild-scss-plugin";
15
- import { createPostcssPlugin } from "./esbuild-postcss-plugin";
13
+ import { createPostcssPlugin, loadPostcssPlugins } from "./esbuild-postcss-plugin";
16
14
 
17
15
  export interface CreateClientEsbuildOptions {
18
16
  /** 패키지 디렉토리 경로 */
@@ -82,15 +80,7 @@ export async function createClientEsbuildContext(
82
80
  const stylesheetErrors: string[] = [];
83
81
 
84
82
  // PostCSS 플러그인 로딩 (튜플 → 인스턴스) — transformStylesheet와 createPostcssPlugin 양쪽에 사용
85
- let loadedPostcssPlugins: AcceptedPlugin[] | undefined;
86
- if (options.postcssPlugins != null && options.postcssPlugins.length > 0) {
87
- const req = createRequire(path.join(options.pkgDir, "package.json"));
88
- loadedPostcssPlugins = options.postcssPlugins.map(([name, pluginOpts]) => {
89
- const pluginFn = req(name);
90
- const fn = pluginFn.default ?? pluginFn;
91
- return pluginOpts != null ? fn(pluginOpts) : fn;
92
- });
93
- }
83
+ const loadedPostcssPlugins = loadPostcssPlugins(options.pkgDir, options.postcssPlugins);
94
84
 
95
85
  // transformStylesheet 콜백 생성
96
86
  const cachePath = path.join(options.pkgDir, ".angular", "cache");
@@ -2,6 +2,8 @@ import type esbuild from "esbuild";
2
2
  import type { AcceptedPlugin } from "postcss";
3
3
  import postcss from "postcss";
4
4
  import fs from "fs";
5
+ import path from "path";
6
+ import { createRequire } from "module";
5
7
  import * as acorn from "acorn";
6
8
  import * as walk from "acorn-walk";
7
9
 
@@ -10,6 +12,22 @@ export interface CreatePostcssPluginOptions {
10
12
  plugins: AcceptedPlugin[];
11
13
  }
12
14
 
15
+ /**
16
+ * PostCSS 플러그인 설정 튜플([name, options])을 패키지 기준으로 로딩해 인스턴스 배열로 변환한다.
17
+ */
18
+ export function loadPostcssPlugins(
19
+ pkgDir: string,
20
+ postcssPlugins: [string, (object | string)?][] | undefined,
21
+ ): AcceptedPlugin[] | undefined {
22
+ if (postcssPlugins == null || postcssPlugins.length === 0) return undefined;
23
+ const req = createRequire(path.join(pkgDir, "package.json"));
24
+ return postcssPlugins.map(([pluginName, pluginOpts]) => {
25
+ const pluginFn = req(pluginName);
26
+ const fn = pluginFn.default ?? pluginFn;
27
+ return pluginOpts != null ? fn(pluginOpts) : fn;
28
+ });
29
+ }
30
+
13
31
  export function createPostcssPlugin(options: CreatePostcssPluginOptions): esbuild.Plugin {
14
32
  return {
15
33
  name: "sd-postcss",