@simple-auth-kit/cli 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/lib/copy.ts +1 -1
  2. package/package.json +1 -1
  3. package/registry/combos/express-drizzle/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
  4. package/registry/combos/express-drizzle/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +23 -7
  5. package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/middleware}/response-envelope.middleware.ts +15 -3
  6. package/registry/combos/express-drizzle/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +32 -9
  7. package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
  8. package/registry/combos/express-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
  9. package/registry/combos/express-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +20 -6
  10. package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
  11. package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +395 -0
  12. package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +52 -19
  13. package/registry/combos/express-drizzle/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
  14. package/registry/combos/express-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +28 -10
  15. package/registry/combos/express-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +239 -48
  16. package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +497 -0
  17. package/registry/combos/express-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +480 -109
  18. package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +69 -23
  19. package/registry/combos/express-drizzle/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
  20. package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +147 -28
  21. package/registry/combos/express-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +30 -11
  22. package/registry/combos/express-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +319 -70
  23. package/registry/combos/express-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +127 -30
  24. package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +534 -0
  25. package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +194 -0
  26. package/registry/combos/express-drizzle/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +544 -122
  27. package/registry/combos/express-prisma/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
  28. package/registry/combos/express-prisma/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +19 -6
  29. package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/middleware}/response-envelope.middleware.ts +15 -3
  30. package/registry/combos/express-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +27 -9
  31. package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
  32. package/registry/combos/express-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
  33. package/registry/combos/express-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
  34. package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
  35. package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +398 -0
  36. package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +56 -21
  37. package/registry/combos/express-prisma/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
  38. package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +66 -18
  39. package/registry/combos/express-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  40. package/registry/combos/express-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +199 -48
  41. package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +508 -0
  42. package/registry/combos/express-prisma/variants/base/src/seed.ts +77 -19
  43. package/registry/combos/express-prisma/variants/base/src/{auth.service.ts → services/auth.service.ts} +454 -105
  44. package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +80 -25
  45. package/registry/combos/express-prisma/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
  46. package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +147 -28
  47. package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +72 -20
  48. package/registry/combos/express-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  49. package/registry/combos/express-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +272 -64
  50. package/registry/combos/express-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +86 -23
  51. package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +545 -0
  52. package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +194 -0
  53. package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +108 -28
  54. package/registry/combos/express-prisma/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +518 -119
  55. package/registry/combos/nestjs-drizzle/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
  56. package/registry/combos/nestjs-drizzle/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
  57. package/registry/combos/nestjs-drizzle/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
  58. package/registry/combos/nestjs-drizzle/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
  59. package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/interceptors}/response.interceptor.ts +10 -2
  60. package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +63 -0
  61. package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
  62. package/registry/combos/nestjs-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
  63. package/registry/combos/nestjs-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +21 -7
  64. package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
  65. package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1097 -307
  66. package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +44 -20
  67. package/registry/combos/nestjs-drizzle/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +228 -62
  68. package/registry/combos/nestjs-drizzle/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
  69. package/registry/combos/nestjs-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +27 -10
  70. package/registry/combos/nestjs-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +249 -51
  71. package/registry/combos/nestjs-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +499 -116
  72. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +48 -22
  73. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +269 -70
  74. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
  75. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
  76. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +29 -11
  77. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +323 -71
  78. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +131 -30
  79. package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +988 -0
  80. package/registry/combos/nestjs-prisma/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
  81. package/registry/combos/nestjs-prisma/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
  82. package/registry/combos/nestjs-prisma/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
  83. package/registry/combos/nestjs-prisma/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
  84. package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/interceptors}/response.interceptor.ts +10 -2
  85. package/registry/combos/nestjs-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +25 -8
  86. package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
  87. package/registry/combos/nestjs-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
  88. package/registry/combos/nestjs-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
  89. package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
  90. package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1097 -307
  91. package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +58 -27
  92. package/registry/combos/nestjs-prisma/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +469 -110
  93. package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.gateway.ts → gateways/audit-log.gateway.ts} +22 -6
  94. package/registry/combos/nestjs-prisma/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
  95. package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +97 -26
  96. package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  97. package/registry/combos/nestjs-prisma/variants/base/src/{country.repository.ts → repositories/country.repository.ts} +98 -25
  98. package/registry/combos/nestjs-prisma/variants/base/src/{customer.repository.ts → repositories/customer.repository.ts} +106 -27
  99. package/registry/combos/nestjs-prisma/variants/base/src/{language.repository.ts → repositories/language.repository.ts} +93 -25
  100. package/registry/combos/nestjs-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +229 -55
  101. package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +77 -19
  102. package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +1064 -0
  103. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +54 -26
  104. package/registry/combos/nestjs-prisma/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +544 -128
  105. package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
  106. package/registry/combos/nestjs-prisma/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
  107. package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +103 -28
  108. package/registry/combos/nestjs-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  109. package/registry/combos/nestjs-prisma/variants/workspaces/src/{country.repository.ts → repositories/country.repository.ts} +127 -26
  110. package/registry/combos/nestjs-prisma/variants/workspaces/src/{customer.repository.ts → repositories/customer.repository.ts} +137 -28
  111. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +243 -0
  112. package/registry/combos/nestjs-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +314 -74
  113. package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +94 -24
  114. package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +108 -28
  115. package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +961 -0
  116. package/registry.json +4 -4
  117. package/simple-auth-kit.ts +42 -11
  118. package/registry/combos/express-drizzle/shared/src/auth.router.ts +0 -266
  119. package/registry/combos/express-drizzle/shared/src/password-reset.repository.ts +0 -35
  120. package/registry/combos/express-drizzle/variants/base/src/admin.router.ts +0 -307
  121. package/registry/combos/express-drizzle/variants/workspaces/src/admin.router.ts +0 -323
  122. package/registry/combos/express-drizzle/variants/workspaces/src/workspace.router.ts +0 -123
  123. package/registry/combos/express-prisma/shared/src/auth.router.ts +0 -269
  124. package/registry/combos/express-prisma/shared/src/password-reset.repository.ts +0 -29
  125. package/registry/combos/express-prisma/variants/base/src/admin.router.ts +0 -314
  126. package/registry/combos/express-prisma/variants/workspaces/src/admin.router.ts +0 -330
  127. package/registry/combos/express-prisma/variants/workspaces/src/workspace.router.ts +0 -123
  128. package/registry/combos/nestjs-drizzle/shared/src/oauth.repository.ts +0 -36
  129. package/registry/combos/nestjs-drizzle/shared/src/password-reset.repository.ts +0 -29
  130. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.service.ts +0 -553
  131. package/registry/combos/nestjs-prisma/shared/src/password-reset.repository.ts +0 -29
  132. package/registry/combos/nestjs-prisma/variants/base/src/auth.service.ts +0 -618
  133. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.service.ts +0 -509
  134. package/registry/combos/nestjs-prisma/variants/workspaces/src/language.repository.ts +0 -143
@@ -1,509 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { BadRequestException, ConflictException, HttpException, HttpStatus, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
3
- import { hashPassword, verifyPassword } from "@/lib/auth/core/crypto.js";
4
- import {
5
- buildAuthorizationUrl,
6
- APPLE_OIDC_PROVIDER,
7
- completeOAuthLogin,
8
- exchangeCodeForTokens,
9
- GOOGLE_OIDC_PROVIDER,
10
- OAuthProviderDescriptor,
11
- signAppleClientSecret,
12
- verifyIdTokenAndExtractProfile,
13
- } from "@/lib/auth/core/oauth.js";
14
- import { requestPasswordReset as coreRequestPasswordReset, resetPassword as coreResetPassword } from "@/lib/auth/core/password-reset.js";
15
- import { checkRateLimit, type RateLimitDeps } from "@/lib/auth/core/rate-limit.js";
16
- import {
17
- blockUser,
18
- createSession,
19
- deactivateUser,
20
- revokeAccessToken,
21
- revokeAllSessionsForUser,
22
- revokeOtherSessionsForUser,
23
- revokeSession,
24
- rotateRefreshToken,
25
- } from "@/lib/auth/core/session-policy.js";
26
- import { signAccessToken, signRefreshToken, signTwoFactorChallengeToken, verifyRefreshToken, verifyTwoFactorChallengeToken } from "@/lib/auth/core/token-service.js";
27
- import { buildTotpProvisioningUri, generateBackupCodes, generateTotpSecret, verifyTotpCode } from "@/lib/auth/core/two-factor.js";
28
- import type { Revoker } from "@/lib/auth/core/types.js";
29
- import { AUTH_CONFIG, AuthConfig } from "./auth.config.js";
30
- import type { AuthzContext } from "./authz.guard.js";
31
- import { AuditLogEntry, AuditLogListFilter, AuditLogRepository, toAuditLogEntry } from "./audit-log.repository.js";
32
- import type { Paginated } from "./pagination.js";
33
- import { PrismaClient } from "../generated/prisma/client.js";
34
- import { KeyProviderService } from "./key-provider.js";
35
- import { OAuthRepository } from "./oauth.repository.js";
36
- import { PasswordResetRepository } from "./password-reset.repository.js";
37
- import { RATE_LIMIT_STORE } from "./rate-limit.store.js";
38
- import { MemberListFilter, MemberListResult, MemberSummary, PermissionInput, PermissionSummary, RbacRepository, RoleSummary, toMemberSummary } from "./rbac.repository.js";
39
- import { SessionRepository } from "./session.repository.js";
40
- import { TwoFactorRepository } from "./two-factor.repository.js";
41
- import { toId, toIdOrNull } from "./id.helper.js";
42
-
43
- export interface AuthTokens {
44
- accessToken: string;
45
- refreshToken: string;
46
- sessionId: string;
47
- }
48
-
49
- export interface TwoFactorChallenge {
50
- twoFactorRequired: true;
51
- challengeToken: string;
52
- }
53
-
54
- /** `PATCH /auth/me`'s return shape — the same across every combo, workspace-scoped or not, since a profile isn't a workspace concept. */
55
- export interface SelfProfile {
56
- id: string;
57
- email: string;
58
- firstName: string | null;
59
- lastName: string | null;
60
- displayName: string | null;
61
- phone: string | null;
62
- username: string | null;
63
- photo: string | null;
64
- createdAt: string;
65
- }
66
-
67
- @Injectable()
68
- export class AuthService {
69
- constructor(
70
- @Inject(PrismaClient) private readonly prisma: PrismaClient,
71
- @Inject(SessionRepository) private readonly sessions: SessionRepository,
72
- @Inject(KeyProviderService) private readonly keys: KeyProviderService,
73
- @Inject(RATE_LIMIT_STORE) private readonly rateLimit: RateLimitDeps,
74
- @Inject(AuditLogRepository) private readonly auditLog: AuditLogRepository,
75
- @Inject(RbacRepository) private readonly rbac: RbacRepository,
76
- @Inject(TwoFactorRepository) private readonly twoFactor: TwoFactorRepository,
77
- @Inject(OAuthRepository) private readonly oauth: OAuthRepository,
78
- @Inject(PasswordResetRepository) private readonly passwordReset: PasswordResetRepository,
79
- @Inject(AUTH_CONFIG) private readonly config: AuthConfig,
80
- ) {}
81
-
82
- // Joining or creating a workspace is a separate, explicit call — see WorkspaceController.
83
- async signup(input: {
84
- email: string;
85
- password: string;
86
- firstName?: string;
87
- lastName?: string;
88
- displayName?: string;
89
- phone?: string;
90
- username?: string;
91
- userAgent?: string;
92
- ip?: string;
93
- }): Promise<AuthTokens> {
94
- const existing = await this.prisma.user.findUnique({ where: { email: input.email } });
95
- if (existing) throw new ConflictException("email already registered");
96
-
97
- const passwordHash = await hashPassword(input.password);
98
- const user = await this.prisma.user.create({
99
- data: {
100
- email: input.email,
101
- passwordHash,
102
- firstName: input.firstName,
103
- lastName: input.lastName,
104
- displayName: input.displayName,
105
- phone: input.phone,
106
- username: input.username,
107
- },
108
- });
109
- return this.issueSessionTokens(user, { userAgent: input.userAgent, ip: input.ip });
110
- }
111
-
112
- async login(input: { identifier: string; password: string; userAgent?: string; ip?: string }): Promise<AuthTokens | TwoFactorChallenge> {
113
- const { allowed } = await checkRateLimit(this.rateLimit, "login", input.identifier);
114
- if (!allowed) throw new HttpException("too many login attempts", HttpStatus.TOO_MANY_REQUESTS);
115
-
116
- const user = await this.prisma.user.findFirst({
117
- where: { OR: [{ email: input.identifier }, { username: input.identifier }, { phone: input.identifier }] },
118
- });
119
- if (!user || user.blocked || !user.isActive || user.isDeleted || !user.passwordHash) throw new UnauthorizedException("invalid credentials");
120
-
121
- const valid = await verifyPassword(user.passwordHash, input.password);
122
- if (!valid) throw new UnauthorizedException("invalid credentials");
123
-
124
- if (user.twoFactorEnabled) {
125
- const key = await this.keys.getActiveKey();
126
- const { token } = await signTwoFactorChallengeToken({ activeKey: key }, user.id.toString()); // core-facing: sub must be string
127
- return { twoFactorRequired: true, challengeToken: token };
128
- }
129
-
130
- return this.issueSessionTokens(user, { userAgent: input.userAgent, ip: input.ip });
131
- }
132
-
133
- async loginTwoFactor(input: { challengeToken: string; code: string; userAgent?: string; ip?: string }): Promise<AuthTokens> {
134
- const { sub } = await verifyTwoFactorChallengeToken({ secret: this.keys.secret }, input.challengeToken);
135
-
136
- const user = await this.prisma.user.findUnique({ where: { id: toId(sub) } });
137
- if (!user || user.blocked || !user.isActive || user.isDeleted || !user.twoFactorEnabled || !user.twoFactorSecret) throw new UnauthorizedException("invalid credentials");
138
-
139
- const validTotp = verifyTotpCode(user.twoFactorSecret, input.code);
140
- const validBackup = !validTotp && (await this.twoFactor.consumeBackupCode(user.id, input.code));
141
- if (!validTotp && !validBackup) {
142
- await this.sessions.appendAuditEvent({ type: "two_factor_challenge_failed", userId: user.id.toString() }); // core-facing: string
143
- throw new UnauthorizedException("invalid two-factor code");
144
- }
145
-
146
- return this.issueSessionTokens(user, { userAgent: input.userAgent, ip: input.ip });
147
- }
148
-
149
- async enrollTwoFactor(userId: string): Promise<{ secret: string; provisioningUri: string }> {
150
- const idBig = toId(userId);
151
- const user = await this.prisma.user.findUniqueOrThrow({ where: { id: idBig } });
152
- const secret = generateTotpSecret();
153
- // Not enabled yet — confirmTwoFactor() flips that.
154
- await this.prisma.user.update({ where: { id: idBig }, data: { twoFactorSecret: secret, twoFactorEnabled: false } });
155
- return { secret, provisioningUri: buildTotpProvisioningUri({ secret, accountName: user.email, issuer: this.config.twoFactorIssuer }) };
156
- }
157
-
158
- async confirmTwoFactor(userId: string, code: string): Promise<{ backupCodes: string[] }> {
159
- const idBig = toId(userId);
160
- const user = await this.prisma.user.findUniqueOrThrow({ where: { id: idBig } });
161
- if (!user.twoFactorSecret) throw new BadRequestException("call enrollTwoFactor first");
162
- if (!verifyTotpCode(user.twoFactorSecret, code)) throw new UnauthorizedException("invalid two-factor code");
163
-
164
- const backupCodes = generateBackupCodes();
165
- await this.twoFactor.saveBackupCodes(idBig, backupCodes.map((c) => c.hash));
166
- await this.prisma.user.update({ where: { id: idBig }, data: { twoFactorEnabled: true } });
167
- await this.sessions.appendAuditEvent({ type: "two_factor_enabled", userId });
168
- return { backupCodes: backupCodes.map((c) => c.code) };
169
- }
170
-
171
- async disableTwoFactor(userId: string, code: string): Promise<void> {
172
- const idBig = toId(userId);
173
- const user = await this.prisma.user.findUniqueOrThrow({ where: { id: idBig } });
174
- if (!user.twoFactorEnabled || !user.twoFactorSecret) throw new BadRequestException("two-factor is not enabled");
175
-
176
- const validTotp = verifyTotpCode(user.twoFactorSecret, code);
177
- const validBackup = !validTotp && (await this.twoFactor.consumeBackupCode(idBig, code));
178
- if (!validTotp && !validBackup) throw new UnauthorizedException("invalid two-factor code");
179
-
180
- await this.prisma.user.update({ where: { id: idBig }, data: { twoFactorEnabled: false, twoFactorSecret: null } });
181
- await this.twoFactor.clearBackupCodes(idBig);
182
- await this.sessions.appendAuditEvent({ type: "two_factor_disabled", userId });
183
- }
184
-
185
- // Always succeeds from the caller's point of view — an unknown email or a throttled request
186
- // looks identical, to avoid enumeration.
187
- async requestPasswordReset(email: string): Promise<void> {
188
- const user = await this.prisma.user.findUnique({ where: { email } });
189
- if (!user || user.isDeleted) return;
190
-
191
- const { allowed } = await checkRateLimit(this.rateLimit, "password-reset", email);
192
- if (!allowed) return;
193
-
194
- const { token } = await coreRequestPasswordReset(this.passwordReset, user.id.toString());
195
- if (this.config.sendPasswordResetEmail) await this.config.sendPasswordResetEmail(email, token);
196
- }
197
-
198
- async resetPassword(token: string, newPassword: string): Promise<void> {
199
- const { userId } = await coreResetPassword(this.passwordReset, token, newPassword);
200
- // The old password is no longer trusted, so neither are its sessions.
201
- await revokeAllSessionsForUser(this.sessions, userId, { userId });
202
- }
203
-
204
- async startOAuth(provider: string): Promise<{ url: string }> {
205
- const state = Buffer.from(JSON.stringify({ nonce: randomUUID() })).toString("base64url");
206
-
207
- if (provider === "google") {
208
- const creds = this.config.oauthProviders.google;
209
- if (!creds) throw new BadRequestException("google OAuth is not configured");
210
- return { url: buildAuthorizationUrl(GOOGLE_OIDC_PROVIDER, { clientId: creds.clientId, redirectUri: creds.redirectUri, state }) };
211
- }
212
- if (provider === "apple") {
213
- const creds = this.config.oauthProviders.apple;
214
- if (!creds) throw new BadRequestException("apple OAuth is not configured");
215
- return { url: buildAuthorizationUrl(APPLE_OIDC_PROVIDER, { clientId: creds.clientId, redirectUri: creds.redirectUri, state }) };
216
- }
217
- throw new BadRequestException(`unknown OAuth provider "${provider}"`);
218
- }
219
-
220
- async completeOAuthCallback(provider: string, code: string, _state: string): Promise<AuthTokens> {
221
- let providerDescriptor: OAuthProviderDescriptor;
222
- let clientId: string;
223
- let idToken: string;
224
-
225
- if (provider === "google") {
226
- const creds = this.config.oauthProviders.google;
227
- if (!creds) throw new BadRequestException("google OAuth is not configured");
228
- providerDescriptor = GOOGLE_OIDC_PROVIDER;
229
- clientId = creds.clientId;
230
- idToken = (
231
- await exchangeCodeForTokens(GOOGLE_OIDC_PROVIDER, { clientId: creds.clientId, clientSecret: creds.clientSecret, redirectUri: creds.redirectUri, code })
232
- ).idToken;
233
- } else if (provider === "apple") {
234
- const creds = this.config.oauthProviders.apple;
235
- if (!creds) throw new BadRequestException("apple OAuth is not configured");
236
- providerDescriptor = APPLE_OIDC_PROVIDER;
237
- clientId = creds.clientId;
238
- const clientSecret = await signAppleClientSecret({ teamId: creds.teamId, clientId: creds.clientId, keyId: creds.keyId, privateKeyPem: creds.privateKey });
239
- idToken = (await exchangeCodeForTokens(APPLE_OIDC_PROVIDER, { clientId: creds.clientId, clientSecret, redirectUri: creds.redirectUri, code })).idToken;
240
- } else {
241
- throw new BadRequestException(`unknown OAuth provider "${provider}"`);
242
- }
243
-
244
- const profile = await verifyIdTokenAndExtractProfile(providerDescriptor, { clientId, idToken });
245
- const { userId } = await completeOAuthLogin(this.oauth, { provider, profile });
246
-
247
- const user = await this.prisma.user.findUniqueOrThrow({ where: { id: toId(userId) } });
248
- if (user.blocked || !user.isActive || user.isDeleted) throw new UnauthorizedException("account is blocked");
249
- return this.issueSessionTokens(user, { provider });
250
- }
251
-
252
- // Pinned to the caller's workspace — the filter argument cannot widen it.
253
- async listAuditLog(ctx: AuthzContext, filter: AuditLogListFilter): Promise<Paginated<AuditLogEntry>> {
254
- const { items, meta } = await this.auditLog.list({ ...filter, workspaceId: ctx.workspaceId });
255
- return { items: items.map(toAuditLogEntry), meta };
256
- }
257
-
258
- async getTwoFactorStatus(userId: string): Promise<{ twoFactorEnabled: boolean }> {
259
- const user = await this.prisma.user.findUniqueOrThrow({ where: { id: toId(userId) }, select: { twoFactorEnabled: true } });
260
- return { twoFactorEnabled: user.twoFactorEnabled };
261
- }
262
-
263
- async listActiveSessions(userId: string): Promise<Array<{ id: string; createdAt: string; expiresAt: string; provider?: string; userAgent?: string; ip?: string }>> {
264
- const rows = await this.prisma.session.findMany({
265
- where: { userId: toId(userId), isRevoked: false, expiresAt: { gt: new Date() } },
266
- orderBy: { createdAt: "desc" },
267
- });
268
- return rows.map((row) => ({
269
- id: row.id.toString(),
270
- createdAt: row.createdAt.toISOString(),
271
- expiresAt: row.expiresAt.toISOString(),
272
- provider: row.provider ?? undefined,
273
- userAgent: row.userAgent ?? undefined,
274
- ip: row.ip ?? undefined,
275
- }));
276
- }
277
-
278
- // Every method below takes the caller's AuthzContext and reaches the database through a
279
- // workspace-scoped query — an admin of one workspace has no expressible way to name a row in
280
- // another.
281
-
282
- async listUsers(ctx: AuthzContext, filter: MemberListFilter): Promise<MemberListResult> {
283
- const { items, meta } = await this.rbac.listMembers(ctx.workspaceId, filter);
284
- return { items: items.map(toMemberSummary), meta };
285
- }
286
-
287
- async getUser(ctx: AuthzContext, userId: string): Promise<MemberSummary> {
288
- return this.rbac.getMember(ctx.workspaceId, userId);
289
- }
290
-
291
- async updateUser(
292
- ctx: AuthzContext,
293
- userId: string,
294
- input: { firstName?: string | null; lastName?: string | null; displayName?: string | null; phone?: string | null; username?: string | null; photo?: string | null },
295
- actorUserId: string | null,
296
- ): Promise<MemberSummary> {
297
- return this.rbac.updateMember(ctx.workspaceId, userId, input, actorUserId);
298
- }
299
-
300
- async deleteUser(ctx: AuthzContext, userId: string, actorUserId: string | null, reason?: string): Promise<void> {
301
- await this.rbac.deleteMember(ctx.workspaceId, userId, actorUserId, reason);
302
- }
303
-
304
- // The catalog is global; what is workspace-scoped is which of this workspace's roles point at it.
305
- async listPermissions(activeOnly?: boolean): Promise<{ permissions: PermissionSummary[] }> {
306
- return { permissions: await this.rbac.listPermissions(activeOnly) };
307
- }
308
-
309
- async definePermission(input: PermissionInput, actorUserId: string | null): Promise<PermissionSummary> {
310
- return this.rbac.upsertPermission(input, actorUserId);
311
- }
312
-
313
- async listRoles(ctx: AuthzContext, activeOnly?: boolean): Promise<{ roles: RoleSummary[] }> {
314
- return { roles: await this.rbac.listRoles(ctx.workspaceId, activeOnly) };
315
- }
316
-
317
- async createRole(
318
- ctx: AuthzContext,
319
- input: { slug: string; name?: string; displayName?: string; description?: string | null },
320
- actorUserId: string | null,
321
- ): Promise<RoleSummary> {
322
- return this.rbac.createRole(ctx.workspaceId, input, actorUserId);
323
- }
324
-
325
- async updateRole(
326
- ctx: AuthzContext,
327
- roleId: string,
328
- input: { name?: string; displayName?: string; description?: string | null; isActive?: boolean },
329
- actorUserId: string | null,
330
- ): Promise<RoleSummary> {
331
- return this.rbac.updateRole(ctx.workspaceId, roleId, input, actorUserId);
332
- }
333
-
334
- async deleteRole(ctx: AuthzContext, roleId: string, actorUserId: string | null, reason?: string): Promise<void> {
335
- await this.rbac.deleteRole(ctx.workspaceId, roleId, actorUserId, reason);
336
- }
337
-
338
- async attachPermissionToRole(ctx: AuthzContext, roleId: string, permissionSlug: string, actorUserId: string | null): Promise<void> {
339
- await this.rbac.attachPermissionToRole(ctx.workspaceId, roleId, permissionSlug, actorUserId);
340
- }
341
-
342
- async detachPermissionFromRole(ctx: AuthzContext, roleId: string, permissionSlug: string): Promise<void> {
343
- await this.rbac.detachPermissionFromRole(ctx.workspaceId, roleId, permissionSlug);
344
- }
345
-
346
- async assignRole(ctx: AuthzContext, userId: string, roleSlug: string): Promise<void> {
347
- await this.rbac.assignRoleToMember(ctx.workspaceId, userId, roleSlug);
348
- await this.auditLog.append({ type: "role_assigned", userId, role: roleSlug }, { workspaceId: ctx.workspaceId });
349
- }
350
-
351
- async revokeRole(ctx: AuthzContext, userId: string, roleSlug: string): Promise<void> {
352
- await this.rbac.revokeRoleFromMember(ctx.workspaceId, userId, roleSlug);
353
- await this.auditLog.append({ type: "role_revoked", userId, role: roleSlug }, { workspaceId: ctx.workspaceId });
354
- }
355
-
356
- async grantPermission(ctx: AuthzContext, userId: string, permissionSlug: string, actorUserId: string | null): Promise<void> {
357
- await this.rbac.grantPermissionToMember(ctx.workspaceId, userId, permissionSlug, actorUserId);
358
- await this.auditLog.append({ type: "permission_granted", userId, permission: permissionSlug }, { workspaceId: ctx.workspaceId });
359
- }
360
-
361
- async revokePermission(ctx: AuthzContext, userId: string, permissionSlug: string): Promise<void> {
362
- await this.rbac.revokePermissionFromMember(ctx.workspaceId, userId, permissionSlug);
363
- await this.auditLog.append({ type: "permission_revoked", userId, permission: permissionSlug }, { workspaceId: ctx.workspaceId });
364
- }
365
-
366
- async refresh(refreshToken: string): Promise<Omit<AuthTokens, "sessionId">> {
367
- const key = await this.keys.getActiveKey();
368
- const presented = await verifyRefreshToken({ secret: this.keys.secret }, refreshToken);
369
- const { session, nextJti } = await rotateRefreshToken(this.sessions, presented);
370
-
371
- const user = await this.prisma.user.findUnique({ where: { id: toId(session.userId) } });
372
- if (!user || user.blocked || !user.isActive || user.isDeleted) throw new UnauthorizedException("invalid credentials");
373
-
374
- const access = await signAccessToken(
375
- { activeKey: key },
376
- { sub: user.id.toString(), sessionId: session.id },
377
- { ttlSeconds: this.config.accessTokenTtlSeconds },
378
- );
379
- const refresh = await signRefreshToken(
380
- { activeKey: key },
381
- { sub: user.id.toString(), sessionId: session.id, sv: session.sessionVersion },
382
- { jti: nextJti, ttlSeconds: this.config.refreshTokenTtlSeconds },
383
- );
384
- return { accessToken: access.token, refreshToken: refresh.token };
385
- }
386
-
387
- async logout(sessionId: string, accessJti: string, accessRemainingTtlSeconds: number, revoker?: Revoker): Promise<void> {
388
- await revokeSession(this.sessions, sessionId, revoker);
389
- await revokeAccessToken(this.sessions, accessJti, accessRemainingTtlSeconds);
390
- }
391
-
392
- async logoutAll(userId: string, revoker?: Revoker): Promise<void> {
393
- await revokeAllSessionsForUser(this.sessions, userId, revoker);
394
- }
395
-
396
- async logoutOthers(userId: string, keepSessionId: string, revoker?: Revoker): Promise<void> {
397
- await revokeOtherSessionsForUser(this.sessions, userId, keepSessionId, revoker);
398
- }
399
-
400
- async changePassword(userId: string, currentSessionId: string, input: { currentPassword: string; newPassword: string }): Promise<void> {
401
- const userIdBig = toId(userId);
402
- const user = await this.prisma.user.findUnique({ where: { id: userIdBig } });
403
- if (!user || user.blocked || !user.isActive || user.isDeleted || !user.passwordHash) throw new UnauthorizedException("invalid credentials");
404
-
405
- const valid = await verifyPassword(user.passwordHash, input.currentPassword);
406
- if (!valid) throw new UnauthorizedException("invalid credentials");
407
-
408
- const passwordHash = await hashPassword(input.newPassword);
409
- await this.prisma.user.update({ where: { id: userIdBig }, data: { passwordHash, updatedBy: userIdBig } });
410
- // The old password is no longer trusted everywhere else it's signed in — but leave the
411
- // session making this very call alone, the same courtesy `logoutOthers` extends.
412
- await revokeOtherSessionsForUser(this.sessions, userId, currentSessionId, { userId });
413
- }
414
-
415
- /** Backs both `GET`- and `PATCH /auth/me` — deliberately not workspace-scoped, see `updateProfile`. */
416
- private toSelfProfile(user: { id: bigint; email: string; firstName: string | null; lastName: string | null; displayName: string | null; phone: string | null; username: string | null; photo: string | null; createdAt: Date }): SelfProfile {
417
- return {
418
- id: user.id.toString(),
419
- email: user.email,
420
- firstName: user.firstName,
421
- lastName: user.lastName,
422
- displayName: user.displayName,
423
- phone: user.phone,
424
- username: user.username,
425
- photo: user.photo,
426
- createdAt: user.createdAt.toISOString(),
427
- };
428
- }
429
-
430
- /** Self-service — no `users:manage` permission required, callable by anyone on their own row, and deliberately not workspace-scoped, see `updateProfile`. */
431
- async getProfile(userId: string): Promise<SelfProfile> {
432
- const userIdBig = toId(userId);
433
- const user = await this.prisma.user.findUnique({ where: { id: userIdBig, isDeleted: false } });
434
- if (!user) throw new UnauthorizedException("invalid credentials");
435
- return this.toSelfProfile(user);
436
- }
437
-
438
- /**
439
- * Self-service — no `users:manage` permission required, the caller's own row only, and
440
- * deliberately not workspace-scoped: a profile belongs to the account, not to any one
441
- * membership, so this updates the same `User` row `updateMember` would, without requiring an
442
- * `X-Workspace-Id` or a membership in it.
443
- */
444
- async updateProfile(
445
- userId: string,
446
- input: { firstName?: string | null; lastName?: string | null; displayName?: string | null; phone?: string | null; username?: string | null; photo?: string | null },
447
- ): Promise<SelfProfile> {
448
- const userIdBig = toId(userId);
449
- const existing = await this.prisma.user.findUnique({ where: { id: userIdBig, isDeleted: false } });
450
- if (!existing) throw new UnauthorizedException("invalid credentials");
451
- const user = await this.prisma.user.update({ where: { id: userIdBig }, data: { ...input, updatedBy: userIdBig } });
452
- return this.toSelfProfile(user);
453
- }
454
-
455
- // Gated on the target being a member of the caller's workspace — otherwise an admin of one
456
- // workspace could disable an account they have no relationship with.
457
- async block(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
458
- await this.rbac.requireMember(ctx.workspaceId, userId);
459
- await this.prisma.user.update({ where: { id: toId(userId) }, data: { blocked: true, updatedBy: toIdOrNull(revoker?.userId) } });
460
- await blockUser(this.sessions, userId, revoker);
461
- // Belt and braces: the block is already enforced on the authentication path, but drop the
462
- // cache entry anyway so nothing about a blocked account is served from memory.
463
- await this.rbac.invalidateMember(userId, ctx.workspaceId);
464
- }
465
-
466
- async unblock(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
467
- await this.rbac.requireMember(ctx.workspaceId, userId);
468
- await this.prisma.user.update({ where: { id: toId(userId) }, data: { blocked: false, updatedBy: toIdOrNull(revoker?.userId) } });
469
- }
470
-
471
- /**
472
- * A routine administrative on/off toggle — distinct from `block`/`unblock`, which is a
473
- * security/moderation action. Both independently deny login; see the note on the `User` model.
474
- */
475
- async deactivate(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
476
- await this.rbac.requireMember(ctx.workspaceId, userId);
477
- await this.prisma.user.update({ where: { id: toId(userId) }, data: { isActive: false, updatedBy: toIdOrNull(revoker?.userId) } });
478
- await deactivateUser(this.sessions, userId, revoker);
479
- await this.rbac.invalidateMember(userId, ctx.workspaceId);
480
- }
481
-
482
- async activate(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
483
- await this.rbac.requireMember(ctx.workspaceId, userId);
484
- await this.prisma.user.update({ where: { id: toId(userId) }, data: { isActive: true, updatedBy: toIdOrNull(revoker?.userId) } });
485
- }
486
-
487
- // The access token carries no authorization: roles/permissions belong to a workspace
488
- // membership, and this token is valid in all of them — each request resolves its own (see
489
- // AuthzGuard). refresh() does not call this, so a token rotation never counts as a fresh login.
490
- // Takes the raw Prisma user row (bigint id) — every call site already has one in hand.
491
- private async issueSessionTokens(user: { id: bigint }, meta: { userAgent?: string; ip?: string; provider?: string }): Promise<AuthTokens> {
492
- await this.prisma.user.update({ where: { id: user.id }, data: { lastLogin: new Date() } });
493
- const session = await createSession(this.sessions, { userId: user.id.toString(), ...meta });
494
- const key = await this.keys.getActiveKey();
495
-
496
- const access = await signAccessToken(
497
- { activeKey: key },
498
- { sub: user.id.toString(), sessionId: session.id },
499
- { ttlSeconds: this.config.accessTokenTtlSeconds },
500
- );
501
- const refresh = await signRefreshToken(
502
- { activeKey: key },
503
- { sub: user.id.toString(), sessionId: session.id, sv: session.sessionVersion },
504
- { jti: session.currentRefreshJti, ttlSeconds: this.config.refreshTokenTtlSeconds },
505
- );
506
-
507
- return { accessToken: access.token, refreshToken: refresh.token, sessionId: session.id };
508
- }
509
- }
@@ -1,143 +0,0 @@
1
- import { ConflictException, Inject, Injectable, NotFoundException } from "@nestjs/common";
2
- import { Prisma, PrismaClient } from "../generated/prisma/client.js";
3
- import { toId, toIdOrNull } from "./id.helper.js";
4
- import { buildPageMeta, normalizeLimit, normalizePage, type Paginated } from "./pagination.js";
5
-
6
- /** A `Language` row as `findUnique`/`findMany` return it. */
7
- export type LanguageRow = Prisma.LanguageGetPayload<object>;
8
-
9
- /** Every column the `Language` table has — no secret to withhold, same reasoning as `toUserSummary`. */
10
- export interface LanguageSummary {
11
- id: string;
12
- uuid: string;
13
- code: string;
14
- name: string;
15
- nativeName: string;
16
- direction: string;
17
- isDefault: boolean;
18
- isActive: boolean;
19
- createdBy: string | null;
20
- updatedBy: string | null;
21
- createdAt: string;
22
- updatedAt: string;
23
- }
24
-
25
- export function toLanguageSummary(row: LanguageRow): LanguageSummary {
26
- return {
27
- id: row.id.toString(),
28
- uuid: row.uuid,
29
- code: row.code,
30
- name: row.name,
31
- nativeName: row.nativeName,
32
- direction: row.direction,
33
- isDefault: row.isDefault,
34
- isActive: row.isActive,
35
- createdBy: row.createdBy?.toString() ?? null,
36
- updatedBy: row.updatedBy?.toString() ?? null,
37
- createdAt: row.createdAt.toISOString(),
38
- updatedAt: row.updatedAt.toISOString(),
39
- };
40
- }
41
-
42
- export interface LanguageListFilter {
43
- search?: string;
44
- page?: number;
45
- limit?: number;
46
- activeOnly?: boolean;
47
- }
48
-
49
- export type LanguageListResult = Paginated<LanguageSummary>;
50
-
51
- export interface LanguageInput {
52
- code: string;
53
- name: string;
54
- nativeName: string;
55
- direction?: string;
56
- isDefault?: boolean;
57
- isActive?: boolean;
58
- }
59
-
60
- // Every method takes `workspaceId` first, matching `WorkspaceRepository`'s signature style, and
61
- // every query's `where` includes it — a language lives in exactly one workspace, and `code`
62
- // uniqueness is checked within that workspace only (see `schema.prisma`'s composite unique constraint).
63
- @Injectable()
64
- export class LanguageRepository {
65
- constructor(@Inject(PrismaClient) private readonly prisma: PrismaClient) {}
66
-
67
- /** Newest-first, page-paginated; `search` matches name/code/nativeName. Returns raw rows — shaping is the caller's job. */
68
- async list(workspaceId: string, filter: LanguageListFilter = {}): Promise<LanguageListResult> {
69
- const page = normalizePage(filter.page);
70
- const limit = normalizeLimit(filter.limit);
71
- const where: Prisma.LanguageWhereInput = {
72
- workspaceId: toId(workspaceId),
73
- isDeleted: false,
74
- isActive: filter.activeOnly ? true : undefined,
75
- OR: filter.search
76
- ? [
77
- { name: { contains: filter.search, mode: "insensitive" } },
78
- { code: { contains: filter.search, mode: "insensitive" } },
79
- { nativeName: { contains: filter.search, mode: "insensitive" } },
80
- ]
81
- : undefined,
82
- };
83
-
84
- const [rows, total] = await this.prisma.$transaction([
85
- this.prisma.language.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limit, skip: (page - 1) * limit }),
86
- this.prisma.language.count({ where }),
87
- ]);
88
-
89
- return { items: rows.map(toLanguageSummary), meta: buildPageMeta(page, limit, total) };
90
- }
91
-
92
- async get(workspaceId: string, languageId: string): Promise<LanguageSummary> {
93
- const row = await this.prisma.language.findUnique({ where: { id: toId(languageId), workspaceId: toId(workspaceId), isDeleted: false } });
94
- if (!row) throw new NotFoundException(`language "${languageId}" not found`);
95
- return toLanguageSummary(row);
96
- }
97
-
98
- async create(workspaceId: string, input: LanguageInput, actorUserId: string | null): Promise<LanguageSummary> {
99
- const workspaceIdBig = toId(workspaceId);
100
- const existing = await this.prisma.language.findFirst({ where: { workspaceId: workspaceIdBig, code: input.code } });
101
- if (existing) throw new ConflictException("a language with this code already exists in this workspace");
102
-
103
- const row = await this.prisma.language.create({
104
- data: { ...input, workspaceId: workspaceIdBig, createdBy: toIdOrNull(actorUserId), updatedBy: toIdOrNull(actorUserId) },
105
- });
106
- return toLanguageSummary(row);
107
- }
108
-
109
- async update(workspaceId: string, languageId: string, input: Partial<LanguageInput>, actorUserId: string | null): Promise<LanguageSummary> {
110
- const workspaceIdBig = toId(workspaceId);
111
- const languageIdBig = toId(languageId);
112
- const existing = await this.prisma.language.findUnique({ where: { id: languageIdBig, workspaceId: workspaceIdBig, isDeleted: false }, select: { id: true } });
113
- if (!existing) throw new NotFoundException(`language "${languageId}" not found`);
114
-
115
- if (input.code !== undefined) {
116
- const clash = await this.prisma.language.findFirst({ where: { workspaceId: workspaceIdBig, NOT: { id: languageIdBig }, code: input.code } });
117
- if (clash) throw new ConflictException("a language with this code already exists in this workspace");
118
- }
119
-
120
- const row = await this.prisma.language.update({ where: { id: languageIdBig }, data: { ...input, updatedBy: toIdOrNull(actorUserId) } });
121
- return toLanguageSummary(row);
122
- }
123
-
124
- // Soft-delete, matching every other table's isDeleted/deletedAt/deletedBy/deletedReason pattern.
125
- async delete(workspaceId: string, languageId: string, actorUserId: string | null, reason?: string): Promise<void> {
126
- const workspaceIdBig = toId(workspaceId);
127
- const languageIdBig = toId(languageId);
128
- const existing = await this.prisma.language.findUnique({ where: { id: languageIdBig, workspaceId: workspaceIdBig, isDeleted: false }, select: { id: true } });
129
- if (!existing) throw new NotFoundException(`language "${languageId}" not found`);
130
- await this.prisma.language.update({
131
- where: { id: languageIdBig },
132
- data: { isDeleted: true, deletedAt: new Date(), deletedBy: toIdOrNull(actorUserId), deletedReason: reason ?? null },
133
- });
134
- }
135
-
136
- async setActive(workspaceId: string, languageId: string, isActive: boolean, actorUserId: string | null): Promise<void> {
137
- const workspaceIdBig = toId(workspaceId);
138
- const languageIdBig = toId(languageId);
139
- const existing = await this.prisma.language.findUnique({ where: { id: languageIdBig, workspaceId: workspaceIdBig, isDeleted: false }, select: { id: true } });
140
- if (!existing) throw new NotFoundException(`language "${languageId}" not found`);
141
- await this.prisma.language.update({ where: { id: languageIdBig }, data: { isActive, updatedBy: toIdOrNull(actorUserId) } });
142
- }
143
- }