@allbluecn/web-app 0.10.3 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -11
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/fetch-json.ts +23 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +10 -2
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
- package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +26 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +36 -13
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +122 -0
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +50 -12
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +114 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +105 -4
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -20,9 +20,10 @@ import { z } from 'zod'
|
|
|
20
20
|
import { prisma } from '@allbluecn/database'
|
|
21
21
|
import { randomBytes } from 'crypto'
|
|
22
22
|
import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
|
|
23
|
+
import { sendTemplateEmail } from '@/server/email'
|
|
23
24
|
|
|
24
25
|
const forgotPasswordSchema = z.object({
|
|
25
|
-
email: z.string().email('请输入有效邮箱'),
|
|
26
|
+
email: z.string().transform((v) => v.toLowerCase().trim()).pipe(z.string().email('请输入有效邮箱')),
|
|
26
27
|
})
|
|
27
28
|
|
|
28
29
|
function generateToken(): string {
|
|
@@ -35,20 +36,39 @@ export const forgotPasswordFn = createServerFn({ method: 'POST' })
|
|
|
35
36
|
await checkAuthRateLimit('forgotPassword', 5, 15 * 60 * 1000)
|
|
36
37
|
const user = await prisma.user.findUnique({
|
|
37
38
|
where: { email: data.email },
|
|
38
|
-
select: { id: true },
|
|
39
|
+
select: { id: true, email: true, locale: true },
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
if (user) {
|
|
42
43
|
const token = generateToken()
|
|
43
44
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
|
|
44
45
|
|
|
46
|
+
let created = false
|
|
45
47
|
try {
|
|
46
48
|
await prisma.passwordResetToken.create({
|
|
47
49
|
data: { token, userId: user.id, expiresAt },
|
|
48
50
|
})
|
|
51
|
+
created = true
|
|
49
52
|
} catch (dbError) {
|
|
50
53
|
console.error('Database error:', dbError instanceof Error ? dbError.message : String(dbError))
|
|
51
54
|
}
|
|
55
|
+
|
|
56
|
+
if (created) {
|
|
57
|
+
const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
58
|
+
await sendTemplateEmail({
|
|
59
|
+
key: 'auth.reset-password',
|
|
60
|
+
to: user.email,
|
|
61
|
+
locale: user.locale ?? 'zh',
|
|
62
|
+
userId: user.id,
|
|
63
|
+
vars: { url: `${appUrl}/reset-password?token=${token}` },
|
|
64
|
+
}).catch((err) => {
|
|
65
|
+
console.error('reset password email failed', {
|
|
66
|
+
userId: user.id,
|
|
67
|
+
templateKey: 'auth.reset-password',
|
|
68
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
return {
|
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { getResolvedOAuthProviders } from '@/server/oauth-config'
|
|
19
20
|
|
|
20
21
|
export interface AuthProvidersInfo {
|
|
21
22
|
googleEnabled: boolean
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export const getAuthProviders = createServerFn({ method: 'GET' }).handler(async (): Promise<AuthProvidersInfo> => {
|
|
25
|
-
const
|
|
26
|
-
return { googleEnabled }
|
|
27
|
-
})
|
|
26
|
+
const providers = await getResolvedOAuthProviders()
|
|
27
|
+
return { googleEnabled: providers.some((p) => p.provider === 'google') }
|
|
28
|
+
})
|
|
@@ -17,22 +17,26 @@
|
|
|
17
17
|
|
|
18
18
|
import { createServerFn } from '@tanstack/react-start'
|
|
19
19
|
import { z } from 'zod'
|
|
20
|
-
import { prisma } from '@allbluecn/database'
|
|
20
|
+
import { prisma, Prisma } from '@allbluecn/database'
|
|
21
21
|
import bcrypt from 'bcryptjs'
|
|
22
22
|
import { getRandomConfig, presetBgColors } from '@/components/settings/personal-team/account/avatar/avatar-config'
|
|
23
23
|
import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
|
|
24
|
+
import { getRegistrationMode, consumeInvitationCode } from '@/server/auth-registration'
|
|
25
|
+
import { sendTemplateEmail, generateEmailToken } from '@/server/email'
|
|
24
26
|
|
|
25
|
-
const
|
|
26
|
-
username: z.string().
|
|
27
|
-
email: z.string().email(),
|
|
28
|
-
password: z.string().min(8),
|
|
29
|
-
invitationCode: z.string().optional(),
|
|
27
|
+
const registerServerSchema = z.object({
|
|
28
|
+
username: z.string().regex(/^[a-zA-Z0-9_-]{3,20}$/),
|
|
29
|
+
email: z.string().transform((v) => v.toLowerCase().trim()).pipe(z.string().email()),
|
|
30
|
+
password: z.string().min(8).max(32).regex(/[a-zA-Z]/).regex(/[0-9]/),
|
|
31
|
+
invitationCode: z.string().optional().default(''),
|
|
30
32
|
})
|
|
31
33
|
|
|
32
34
|
export const registerFn = createServerFn({ method: 'POST' })
|
|
33
|
-
.validator(
|
|
35
|
+
.validator(registerServerSchema)
|
|
34
36
|
.handler(async ({ data }) => {
|
|
35
37
|
await checkAuthRateLimit('register', 5, 15 * 60 * 1000)
|
|
38
|
+
const mode = await getRegistrationMode()
|
|
39
|
+
|
|
36
40
|
const exists = await prisma.user.findFirst({
|
|
37
41
|
where: { OR: [{ email: data.email }, { username: data.username }] },
|
|
38
42
|
select: { id: true },
|
|
@@ -43,16 +47,56 @@ export const registerFn = createServerFn({ method: 'POST' })
|
|
|
43
47
|
const hash = data.username.split('').reduce((h, c) => c.charCodeAt(0) + ((h << 5) - h), 0)
|
|
44
48
|
const bgColor = presetBgColors[Math.abs(hash) % presetBgColors.length]
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
try {
|
|
51
|
+
const user = await prisma.$transaction(async (tx) => {
|
|
52
|
+
let codeId: string | null = null
|
|
53
|
+
if (mode === 'invite') {
|
|
54
|
+
if (!data.invitationCode) throw new Error('请输入邀请码')
|
|
55
|
+
const consumed = await consumeInvitationCode(tx, data.invitationCode)
|
|
56
|
+
if (!consumed.ok) throw new Error(consumed.error)
|
|
57
|
+
codeId = consumed.codeId
|
|
58
|
+
}
|
|
59
|
+
const created = await tx.user.create({
|
|
60
|
+
data: {
|
|
61
|
+
username: data.username,
|
|
62
|
+
email: data.email,
|
|
63
|
+
password: hashedPassword,
|
|
64
|
+
avatarConfig: getRandomConfig() as object,
|
|
65
|
+
bgShape: 'square',
|
|
66
|
+
bgColor,
|
|
67
|
+
status: 'ACTIVE',
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
if (codeId) {
|
|
71
|
+
await tx.invitationUse.create({ data: { codeId, userId: created.id } })
|
|
72
|
+
}
|
|
73
|
+
return created
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const token = generateEmailToken()
|
|
77
|
+
await prisma.emailVerificationToken.create({
|
|
78
|
+
data: { token, userId: user.id, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
|
79
|
+
})
|
|
80
|
+
const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
81
|
+
await sendTemplateEmail({
|
|
82
|
+
key: 'auth.verify-email',
|
|
83
|
+
to: user.email,
|
|
84
|
+
locale: 'zh',
|
|
85
|
+
userId: user.id,
|
|
86
|
+
vars: { url: `${appUrl}/verify-email?token=${token}`, username: user.username },
|
|
87
|
+
}).catch((err) => {
|
|
88
|
+
console.error('verification email failed', {
|
|
89
|
+
userId: user.id,
|
|
90
|
+
templateKey: 'auth.verify-email',
|
|
91
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return { id: user.id, email: user.email, username: user.username }
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
|
98
|
+
throw new Error('用户名或邮箱已存在')
|
|
99
|
+
}
|
|
100
|
+
throw new Error(err instanceof Error ? err.message : '注册失败')
|
|
101
|
+
}
|
|
58
102
|
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { getRegistrationMode } from '@/server/auth-registration'
|
|
20
|
+
|
|
21
|
+
export const getRegistrationModeFn = createServerFn({ method: 'GET' }).handler(
|
|
22
|
+
async () => ({ mode: await getRegistrationMode() }),
|
|
23
|
+
)
|
|
@@ -23,7 +23,7 @@ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
|
|
|
23
23
|
|
|
24
24
|
const resetPasswordSchema = z.object({
|
|
25
25
|
token: z.string().min(1, 'Token is required'),
|
|
26
|
-
password: z.string().min(8, '密码至少 8 位').max(32),
|
|
26
|
+
password: z.string().min(8, '密码至少 8 位').max(32).regex(/[a-zA-Z]/, '密码需包含字母').regex(/[0-9]/, '密码需包含数字'),
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
export const validateResetTokenFn = createServerFn({ method: 'GET' })
|
|
@@ -16,34 +16,17 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
19
20
|
import { z } from 'zod'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
21
|
+
import { getCurrentSession } from '@/lib/session'
|
|
22
|
+
import { ensureVerifyReminder } from '@/server/notifications/verify-reminder'
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
export const createDefaultInvitationFn = createServerFn({ method: 'POST' })
|
|
24
|
+
export const ensureVerifyReminderFn = createServerFn({ method: 'POST' })
|
|
26
25
|
.validator(z.object({}))
|
|
27
26
|
.handler(async () => {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
if (existing) {
|
|
34
|
-
return { message: 'Invitation code already exists', code: existing }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const expiresAt = new Date()
|
|
38
|
-
expiresAt.setDate(expiresAt.getDate() + 1)
|
|
39
|
-
|
|
40
|
-
const invCode = await prisma.invitationCode.create({
|
|
41
|
-
data: {
|
|
42
|
-
code: DEFAULT_CODE,
|
|
43
|
-
maxUses: 1,
|
|
44
|
-
expiresAt,
|
|
45
|
-
},
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
return { message: 'Default invitation code created', code: invCode }
|
|
27
|
+
const session = await getCurrentSession(getRequest())
|
|
28
|
+
const userId = session?.user?.id
|
|
29
|
+
if (!userId) return { reminded: false }
|
|
30
|
+
await ensureVerifyReminder(userId)
|
|
31
|
+
return { reminded: true }
|
|
49
32
|
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const sendTemplateEmailMock = vi.hoisted(() => vi.fn().mockResolvedValue({ sent: true }))
|
|
4
|
+
|
|
5
|
+
const emailSubscriberMock = vi.hoisted(() => ({
|
|
6
|
+
findUnique: vi.fn(),
|
|
7
|
+
upsert: vi.fn(),
|
|
8
|
+
updateMany: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('@allbluecn/database', () => ({
|
|
12
|
+
prisma: { emailSubscriber: emailSubscriberMock },
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('@/server/middlewares/rate-limit', () => ({
|
|
16
|
+
checkAuthRateLimit: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('@/server/email', () => ({
|
|
20
|
+
sendTemplateEmail: (...args: unknown[]) => sendTemplateEmailMock(...args),
|
|
21
|
+
generateEmailToken: () => 'tok-' + 'x'.repeat(64),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
25
|
+
createServerFn: () => {
|
|
26
|
+
let validator: { safeParse: (d: unknown) => { success: boolean; data?: unknown } } | undefined
|
|
27
|
+
let handler: ((args: { data: unknown }) => Promise<unknown>) | undefined
|
|
28
|
+
const chain = {
|
|
29
|
+
validator(v: unknown) {
|
|
30
|
+
validator = v as { safeParse: (d: unknown) => { success: boolean; data?: unknown } }
|
|
31
|
+
return chain
|
|
32
|
+
},
|
|
33
|
+
handler(h: unknown) {
|
|
34
|
+
handler = h as (args: { data: unknown }) => Promise<unknown>
|
|
35
|
+
return async (args: { data: unknown }) => {
|
|
36
|
+
if (validator) {
|
|
37
|
+
const parsed = validator.safeParse(args.data)
|
|
38
|
+
if (!parsed.success) throw new Error('zod validation failed')
|
|
39
|
+
args = { data: parsed.data }
|
|
40
|
+
}
|
|
41
|
+
return handler!(args)
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
return chain
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
import { subscribeChangelogFn, confirmChangelogSubscriptionFn } from '../subscribe'
|
|
50
|
+
|
|
51
|
+
const invoke = async (fn: unknown, data: unknown) =>
|
|
52
|
+
(fn as (args: { data: unknown }) => Promise<unknown>)({ data })
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('subscribeChangelogFn', () => {
|
|
59
|
+
it('已 confirmed 且未退订 → already,不发邮件', async () => {
|
|
60
|
+
emailSubscriberMock.findUnique.mockResolvedValue({
|
|
61
|
+
id: '1', email: 'a@b.com', confirmedAt: new Date(), unsubscribedAt: null,
|
|
62
|
+
})
|
|
63
|
+
const r = await invoke(subscribeChangelogFn, { email: 'A@B.com' })
|
|
64
|
+
expect(r).toEqual({ status: 'already' })
|
|
65
|
+
expect(emailSubscriberMock.upsert).not.toHaveBeenCalled()
|
|
66
|
+
expect(sendTemplateEmailMock).not.toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('未 confirmed → upsert 新 token 并发确认邮件', async () => {
|
|
70
|
+
emailSubscriberMock.findUnique.mockResolvedValue({
|
|
71
|
+
id: '1', email: 'a@b.com', confirmedAt: null, unsubscribedAt: null,
|
|
72
|
+
})
|
|
73
|
+
emailSubscriberMock.upsert.mockResolvedValue({})
|
|
74
|
+
const r = await invoke(subscribeChangelogFn, { email: 'a@b.com', locale: 'en' })
|
|
75
|
+
expect(r).toEqual({ status: 'pending' })
|
|
76
|
+
const upsert = emailSubscriberMock.upsert.mock.calls[0][0]
|
|
77
|
+
expect(upsert.where).toEqual({ email: 'a@b.com' })
|
|
78
|
+
expect(upsert.update.confirmToken).toBe('tok-' + 'x'.repeat(64))
|
|
79
|
+
expect(upsert.update.locale).toBe('en')
|
|
80
|
+
expect(upsert.update.createdAt).toBeInstanceOf(Date)
|
|
81
|
+
const email = sendTemplateEmailMock.mock.calls[0][0]
|
|
82
|
+
expect(email.key).toBe('product.changelog-confirm')
|
|
83
|
+
expect(email.to).toBe('a@b.com')
|
|
84
|
+
expect(email.locale).toBe('en')
|
|
85
|
+
expect(String(email.vars.confirmUrl)).toContain('/changelog/confirm?token=tok-')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('已退订 → 重新发确认邮件', async () => {
|
|
89
|
+
emailSubscriberMock.findUnique.mockResolvedValue({
|
|
90
|
+
id: '1', email: 'a@b.com', confirmedAt: new Date(), unsubscribedAt: new Date(),
|
|
91
|
+
})
|
|
92
|
+
emailSubscriberMock.upsert.mockResolvedValue({})
|
|
93
|
+
const r = await invoke(subscribeChangelogFn, { email: 'a@b.com' })
|
|
94
|
+
expect(r).toEqual({ status: 'pending' })
|
|
95
|
+
expect(sendTemplateEmailMock).toHaveBeenCalledTimes(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('email 为空 → zod 拒绝', async () => {
|
|
99
|
+
await expect(invoke(subscribeChangelogFn, { email: 'nope' })).rejects.toThrow()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('confirmChangelogSubscriptionFn', () => {
|
|
104
|
+
it('updateMany 命中 1 行 → ok', async () => {
|
|
105
|
+
emailSubscriberMock.updateMany.mockResolvedValue({ count: 1 })
|
|
106
|
+
const r = await invoke(confirmChangelogSubscriptionFn, { token: 't' })
|
|
107
|
+
expect(r).toEqual({ ok: true })
|
|
108
|
+
const where = emailSubscriberMock.updateMany.mock.calls[0][0].where
|
|
109
|
+
expect(where.confirmToken).toBe('t')
|
|
110
|
+
expect(where.confirmedAt).toBeNull()
|
|
111
|
+
expect(where.createdAt.gte).toBeInstanceOf(Date)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('token 无效或已确认或过期 → count 0 → not ok(防并发双写)', async () => {
|
|
115
|
+
emailSubscriberMock.updateMany.mockResolvedValue({ count: 0 })
|
|
116
|
+
const r = await invoke(confirmChangelogSubscriptionFn, { token: 'bad' })
|
|
117
|
+
expect(r).toEqual({ ok: false })
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import { prisma } from '@allbluecn/database'
|
|
21
|
+
import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
|
|
22
|
+
import { sendTemplateEmail, generateEmailToken } from '@/server/email'
|
|
23
|
+
|
|
24
|
+
const CONFIRM_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
|
25
|
+
const CONFIRM_TEMPLATE_KEY = 'product.changelog-confirm'
|
|
26
|
+
|
|
27
|
+
const appUrl = () => process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
28
|
+
|
|
29
|
+
const normalizeLocale = (locale: string | undefined) => (locale === 'en' ? 'en' : 'zh')
|
|
30
|
+
|
|
31
|
+
export interface ChangelogSubscribeResult {
|
|
32
|
+
status: 'already' | 'pending'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const subscribeChangelogFn = createServerFn({ method: 'POST' })
|
|
36
|
+
.validator(
|
|
37
|
+
z.object({
|
|
38
|
+
email: z.string().email().transform((v) => v.toLowerCase()),
|
|
39
|
+
locale: z.string().optional(),
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
.handler(async ({ data }): Promise<ChangelogSubscribeResult> => {
|
|
43
|
+
await checkAuthRateLimit('changelogSubscribe', 5, 15 * 60 * 1000)
|
|
44
|
+
|
|
45
|
+
const email = data.email
|
|
46
|
+
const existing = await prisma.emailSubscriber.findUnique({ where: { email } })
|
|
47
|
+
if (existing?.confirmedAt && !existing.unsubscribedAt) {
|
|
48
|
+
return { status: 'already' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const confirmToken = generateEmailToken()
|
|
52
|
+
const locale = normalizeLocale(data.locale)
|
|
53
|
+
const now = new Date()
|
|
54
|
+
await prisma.emailSubscriber.upsert({
|
|
55
|
+
where: { email },
|
|
56
|
+
create: { email, locale, confirmToken },
|
|
57
|
+
// 重订阅时清零 unsubscribedAt,让已退订用户重新订阅后回到「已确认」状态(confirmedAt 保留)。
|
|
58
|
+
update: { confirmToken, locale, createdAt: now, unsubscribedAt: null },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await sendTemplateEmail({
|
|
62
|
+
key: CONFIRM_TEMPLATE_KEY,
|
|
63
|
+
to: email,
|
|
64
|
+
locale,
|
|
65
|
+
vars: { confirmUrl: `${appUrl()}/changelog/confirm?token=${confirmToken}` },
|
|
66
|
+
}).catch((err) => {
|
|
67
|
+
console.error('changelog confirm email failed', {
|
|
68
|
+
templateKey: CONFIRM_TEMPLATE_KEY,
|
|
69
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return { status: 'pending' }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export interface ChangelogConfirmResult {
|
|
77
|
+
ok: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const confirmChangelogSubscriptionFn = createServerFn({ method: 'GET' })
|
|
81
|
+
.validator(z.object({ token: z.string().min(1) }))
|
|
82
|
+
.handler(async ({ data }): Promise<ChangelogConfirmResult> => {
|
|
83
|
+
const floor = new Date(Date.now() - CONFIRM_TOKEN_TTL_MS)
|
|
84
|
+
const claimed = await prisma.emailSubscriber.updateMany({
|
|
85
|
+
where: { confirmToken: data.token, confirmedAt: null, createdAt: { gte: floor } },
|
|
86
|
+
data: { confirmedAt: new Date() },
|
|
87
|
+
})
|
|
88
|
+
return { ok: claimed.count === 1 }
|
|
89
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import { prisma } from '@allbluecn/database'
|
|
21
|
+
import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
|
|
22
|
+
import { verifyUnsubscribeToken } from '@/server/email/deliverability'
|
|
23
|
+
|
|
24
|
+
export interface UnsubscribeByEmailTokenResult {
|
|
25
|
+
ok: boolean
|
|
26
|
+
mechanism?: 'user' | 'subscriber'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const unsubscribeByEmailTokenFn = createServerFn({ method: 'GET' })
|
|
30
|
+
.validator(z.object({ token: z.string().min(1) }))
|
|
31
|
+
.handler(async ({ data }): Promise<UnsubscribeByEmailTokenResult> => {
|
|
32
|
+
// 防 token 爆破扫描:单 IP 15 分钟内最多 20 次
|
|
33
|
+
await checkAuthRateLimit('unsubscribe', 20, 15 * 60 * 1000)
|
|
34
|
+
|
|
35
|
+
// 机制 1:注册用户 HMAC 无状态 token(对 userId+email+category 签名,10 年有效)
|
|
36
|
+
// 命中即 upsert EmailPreference,一键全退(本页不做分类精确退订)
|
|
37
|
+
const payload = verifyUnsubscribeToken(data.token)
|
|
38
|
+
if (payload) {
|
|
39
|
+
await prisma.emailPreference.upsert({
|
|
40
|
+
where: { userId: payload.userId },
|
|
41
|
+
create: { userId: payload.userId, unsubscribedAll: true },
|
|
42
|
+
update: { unsubscribedAll: true },
|
|
43
|
+
})
|
|
44
|
+
return { ok: true, mechanism: 'user' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 机制 2:订阅者 DB token(changelog 确认/退订共用 confirmToken)
|
|
48
|
+
// 幂等保护:仅未退订记录可退订;重复点击已退订链接返回 ok 而非 fail(避免骚扰)
|
|
49
|
+
const now = new Date()
|
|
50
|
+
const claimed = await prisma.emailSubscriber.updateMany({
|
|
51
|
+
where: { confirmToken: data.token, unsubscribedAt: null },
|
|
52
|
+
data: { unsubscribedAt: now },
|
|
53
|
+
})
|
|
54
|
+
if (claimed.count > 0) return { ok: true, mechanism: 'subscriber' }
|
|
55
|
+
|
|
56
|
+
// 重复退订幂等返回:token 命中但已退订 → 也返回 ok(用户可能重复点击链接)
|
|
57
|
+
const alreadyUnsubscribed = await prisma.emailSubscriber.findUnique({
|
|
58
|
+
where: { confirmToken: data.token },
|
|
59
|
+
select: { unsubscribedAt: true },
|
|
60
|
+
})
|
|
61
|
+
if (alreadyUnsubscribed?.unsubscribedAt) {
|
|
62
|
+
return { ok: true, mechanism: 'subscriber' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ok: false }
|
|
66
|
+
})
|
|
@@ -95,7 +95,7 @@ describe("createIterationsFn", () => {
|
|
|
95
95
|
]);
|
|
96
96
|
await expect(
|
|
97
97
|
createIterationsFn({ data: { projectId: "p1", name: "S2", startDate: "2026-09-10", endDate: "2026-09-23" } }),
|
|
98
|
-
).rejects.
|
|
98
|
+
).rejects.toThrow("重叠");
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it("合法日期调用 create 且日期按 UTC 0 点入库", async () => {
|
|
@@ -130,9 +130,7 @@ describe("updateIterationsFn", () => {
|
|
|
130
130
|
archived: false,
|
|
131
131
|
projectId: "p1",
|
|
132
132
|
});
|
|
133
|
-
await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.
|
|
134
|
-
message: expect.stringContaining("不可编辑"),
|
|
135
|
-
});
|
|
133
|
+
await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toThrow("不可编辑");
|
|
136
134
|
});
|
|
137
135
|
|
|
138
136
|
it("改期时校验与其他迭代不重叠", async () => {
|
|
@@ -148,7 +146,7 @@ describe("updateIterationsFn", () => {
|
|
|
148
146
|
]);
|
|
149
147
|
await expect(
|
|
150
148
|
updateIterationsFn({ data: { id: "i1", startDate: "2026-09-18", endDate: "2026-09-25" } }),
|
|
151
|
-
).rejects.
|
|
149
|
+
).rejects.toThrow("重叠");
|
|
152
150
|
});
|
|
153
151
|
});
|
|
154
152
|
|
|
@@ -83,7 +83,7 @@ describe("setStoryIterationFn", () => {
|
|
|
83
83
|
});
|
|
84
84
|
await expect(
|
|
85
85
|
setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } }),
|
|
86
|
-
).rejects.
|
|
86
|
+
).rejects.toThrow("已完成");
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
it("合法目标更新 story 并记录活动", async () => {
|
|
@@ -85,7 +85,7 @@ describe("transferUnfinishedFn", () => {
|
|
|
85
85
|
});
|
|
86
86
|
await expect(
|
|
87
87
|
transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
|
|
88
|
-
).rejects.
|
|
88
|
+
).rejects.toThrow("已完成");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
it("where 断言 state notIn 且 iterationId = 源迭代", async () => {
|
|
@@ -118,7 +118,7 @@ describe("transferUnfinishedFn", () => {
|
|
|
118
118
|
});
|
|
119
119
|
await expect(
|
|
120
120
|
transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
|
|
121
|
-
).rejects.
|
|
121
|
+
).rejects.toThrow("迭代不存在");
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
it("二次调用(无未完成)返回 { count: 0 },不抛错", async () => {
|