@moifort/stack 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -2
- package/bin/stack.ts +109 -0
- package/hooks/shutdown-simulators-after-push.sh +19 -0
- package/package.json +40 -4
- package/routines/daily-health.md +78 -0
- package/routines/deprecations.md +23 -0
- package/routines/sentry-autofix.md +76 -0
- package/routines/stack-upgrade.md +45 -0
- package/rules/00-collaboration.md +81 -0
- package/rules/01-workflow.md +81 -0
- package/rules/02-commits.md +71 -0
- package/rules/03-code-review.md +25 -0
- package/rules/04-push.md +75 -0
- package/rules/05-release.md +40 -0
- package/rules/06-changelog.md +48 -0
- package/rules/07-appstore.md +77 -0
- package/rules/08-copy.md +52 -0
- package/rules/09-backend.md +132 -0
- package/rules/10-ios.md +206 -0
- package/rules/11-ops.md +114 -0
- package/rules/12-ci.md +32 -0
- package/rules/13-ai.md +71 -0
- package/rules/14-infra.md +90 -0
- package/rules/15-tooling.md +81 -0
- package/rules/16-routines.md +69 -0
- package/rules/17-landing.md +76 -0
- package/skills/appstore/SKILL.md +53 -0
- package/skills/domain/SKILL.md +136 -0
- package/skills/graphql/SKILL.md +83 -0
- package/skills/ios-feature/SKILL.md +94 -0
- package/skills/stack-install/SKILL.md +35 -0
- package/skills/stack-upgrade/SKILL.md +27 -0
- package/src/catalogue.ts +33 -0
- package/src/config.ts +20 -0
- package/src/init.ts +63 -0
- package/src/migrations.ts +72 -0
- package/src/migrations.unit.test.ts +44 -0
- package/src/package.ts +8 -0
- package/src/render.ts +13 -0
- package/src/render.unit.test.ts +17 -0
- package/src/sync.int.test.ts +103 -0
- package/src/sync.ts +161 -0
- package/src/templates.ts +121 -0
- package/src/validate-content.ts +65 -0
- package/templates/backend/.env.example +10 -0
- package/templates/backend/.github/workflows/deploy.yml +151 -0
- package/templates/backend/.github/workflows/schema.yml +59 -0
- package/templates/backend/.github/workflows/test-feat.yml +40 -0
- package/templates/backend/.github/workflows/test-int.yml +40 -0
- package/templates/backend/.github/workflows/test-unit.yml +64 -0
- package/templates/backend/docs/api-evolution.md +67 -0
- package/templates/backend/firebase.json +21 -0
- package/templates/backend/firestore.indexes.json +4 -0
- package/templates/backend/firestore.rules +11 -0
- package/templates/backend/infra/auth.tf +54 -0
- package/templates/backend/infra/backend.tf +6 -0
- package/templates/backend/infra/cicd.tf +63 -0
- package/templates/backend/infra/firebase.tf +26 -0
- package/templates/backend/infra/firestore.tf +53 -0
- package/templates/backend/infra/function.tf +171 -0
- package/templates/backend/infra/main.tf +26 -0
- package/templates/backend/infra/observer.tf +18 -0
- package/templates/backend/infra/outputs.tf +30 -0
- package/templates/backend/infra/project.tf +64 -0
- package/templates/backend/infra/scheduler.tf +28 -0
- package/templates/backend/infra/secrets.tf +44 -0
- package/templates/backend/infra/state.tf +13 -0
- package/templates/backend/infra/terraform.tfvars.example +16 -0
- package/templates/backend/infra/variables.tf +104 -0
- package/templates/backend/infra/versions.tf +30 -0
- package/templates/backend/nitro.config.ts +40 -0
- package/templates/backend/package.json +51 -0
- package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
- package/templates/backend/scripts/deprecations/status.ts +50 -0
- package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
- package/templates/backend/scripts/deprecations/usage.ts +79 -0
- package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
- package/templates/backend/scripts/deprecations.ts +224 -0
- package/templates/backend/scripts/export-graphql-schema.ts +11 -0
- package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
- package/templates/backend/scripts/install-terraform.sh +61 -0
- package/templates/backend/scripts/tf +11 -0
- package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
- package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
- package/templates/backend/server/domain/changelog/primitives.ts +19 -0
- package/templates/backend/server/domain/changelog/query.ts +13 -0
- package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
- package/templates/backend/server/domain/changelog/types.ts +9 -0
- package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
- package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
- package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
- package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
- package/templates/backend/server/domain/shared/language.ts +26 -0
- package/templates/backend/server/domain/shared/page.ts +25 -0
- package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
- package/templates/backend/server/domain/shared/primitives.ts +50 -0
- package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
- package/templates/backend/server/domain/shared/types.ts +14 -0
- package/templates/backend/server/domain/user/command.ts +16 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
- package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
- package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
- package/templates/backend/server/domain/user/query.int.test.ts +49 -0
- package/templates/backend/server/domain/user/query.ts +34 -0
- package/templates/backend/server/domain/user/types.ts +20 -0
- package/templates/backend/server/domain/user/use-case.ts +18 -0
- package/templates/backend/server/middleware/auth.ts +54 -0
- package/templates/backend/server/plugins/00-sentry.ts +74 -0
- package/templates/backend/server/plugins/01-request-log.ts +28 -0
- package/templates/backend/server/plugins/02-graphql.ts +69 -0
- package/templates/backend/server/plugins/03-boot-time.ts +21 -0
- package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
- package/templates/backend/server/routes/app-config.get.ts +15 -0
- package/templates/backend/server/routes/graphql.ts +52 -0
- package/templates/backend/server/system/app-support.ts +17 -0
- package/templates/backend/server/system/config/index.ts +18 -0
- package/templates/backend/server/system/config/primitives.ts +20 -0
- package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
- package/templates/backend/server/system/config/types.ts +8 -0
- package/templates/backend/server/system/firebase.ts +19 -0
- package/templates/backend/server/system/identity.ts +18 -0
- package/templates/backend/server/system/logger.ts +119 -0
- package/templates/backend/server/system/logger.unit.test.ts +109 -0
- package/templates/backend/server/system/migration/migrations/index.ts +6 -0
- package/templates/backend/server/system/migration/primitives.ts +18 -0
- package/templates/backend/server/system/migration/runner.ts +52 -0
- package/templates/backend/server/system/migration/types.ts +16 -0
- package/templates/backend/server/system/read-meter.ts +100 -0
- package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
- package/templates/backend/server/system/request-cache.ts +139 -0
- package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
- package/templates/backend/server/system/secret-box.ts +49 -0
- package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
- package/templates/backend/server/test/bdd.ts +12 -0
- package/templates/backend/server/test/fake-firestore.ts +483 -0
- package/templates/backend/server/utils/apollo.ts +13 -0
- package/templates/backend/server/utils/fetch.ts +5 -0
- package/templates/backend/server/utils/firestore.ts +91 -0
- package/templates/backend/server/utils/input.ts +36 -0
- package/templates/backend/server/utils/input.unit.test.ts +43 -0
- package/templates/backend/server/utils/slug.ts +21 -0
- package/templates/backend/tsconfig.json +16 -0
- package/templates/base/.github/workflows/renovate.yml +17 -0
- package/templates/base/CHANGELOG.fr.md +3 -0
- package/templates/base/CHANGELOG.md +7 -0
- package/templates/base/CLAUDE.md +34 -0
- package/templates/base/README.md +12 -0
- package/templates/base/biome.json +47 -0
- package/templates/base/bunfig.toml +2 -0
- package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
- package/templates/base/docs/data-recovery.md +116 -0
- package/templates/base/docs/ideas.md +4 -0
- package/templates/base/project-context.md +43 -0
- package/templates/base/renovate.json +37 -0
- package/templates/ios/.github/workflows/build-ios.yml +168 -0
- package/templates/ios/.github/workflows/ios-upload.yml +308 -0
- package/templates/ios/.github/workflows/release-ios.yml +178 -0
- package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
- package/templates/ios/infra/Gemfile +5 -0
- package/templates/ios/infra/fastlane/Appfile +1 -0
- package/templates/ios/infra/fastlane/Fastfile +54 -0
- package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
- package/templates/ios/infra/ios.tf +28 -0
- package/templates/ios/ios/ExportOptions.plist +26 -0
- package/templates/ios/ios/apollo-codegen-config.json +16 -0
- package/templates/ios/ios/project.yml +94 -0
- package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
- package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
- package/templates/ios/package.merge.json +10 -0
- package/templates/ios/scripts/apollo-codegen.ts +31 -0
- package/templates/ios/scripts/check-ios-components.ts +72 -0
- package/templates/ios/scripts/check-strings.ts +103 -0
- package/templates/ios/scripts/install-device.sh +29 -0
- package/templates/ios/scripts/testflight.ts +213 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { builder } from '~/domain/shared/graphql/builder'
|
|
2
|
+
import * as UserCommand from '~/domain/user/command'
|
|
3
|
+
import * as UserQuery from '~/domain/user/query'
|
|
4
|
+
import * as UserUseCase from '~/domain/user/use-case'
|
|
5
|
+
import { CompleteOnboardingInput } from './inputs'
|
|
6
|
+
import { MeType } from './types'
|
|
7
|
+
|
|
8
|
+
builder.mutationField('completeOnboarding', (t) =>
|
|
9
|
+
t.field({
|
|
10
|
+
type: MeType,
|
|
11
|
+
description:
|
|
12
|
+
'Finish onboarding: save the profile. Returns the updated `Me`, with ' +
|
|
13
|
+
'`onboardingCompleted` now true. Reachable again afterwards, so a user can change ' +
|
|
14
|
+
'the name they are addressed by.',
|
|
15
|
+
args: {
|
|
16
|
+
input: t.arg({
|
|
17
|
+
type: CompleteOnboardingInput,
|
|
18
|
+
required: true,
|
|
19
|
+
description: 'The first name to persist.',
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
resolve: async (_root, { input }, { userId }) => {
|
|
23
|
+
await UserCommand.completeOnboarding(userId, input.firstName)
|
|
24
|
+
return UserQuery.me(userId)
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
builder.mutationField('deleteAccount', (t) =>
|
|
30
|
+
t.boolean({
|
|
31
|
+
description:
|
|
32
|
+
'Permanently delete the signed-in account and all of its data. Irreversible. ' +
|
|
33
|
+
'Required by App Store guideline 5.1.1(v). Does NOT cancel an active App Store ' +
|
|
34
|
+
'subscription: Apple owns that lifecycle. Always returns `true`.',
|
|
35
|
+
resolve: async (_root, _args, { userId }) => {
|
|
36
|
+
await UserUseCase.deleteAccount(userId)
|
|
37
|
+
return true
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { builder } from '~/domain/shared/graphql/builder'
|
|
2
|
+
import * as UserQuery from '~/domain/user/query'
|
|
3
|
+
import { MeType } from './types'
|
|
4
|
+
|
|
5
|
+
builder.queryField('me', (t) =>
|
|
6
|
+
t.field({
|
|
7
|
+
type: MeType,
|
|
8
|
+
description:
|
|
9
|
+
'The signed-in user, resolved from the auth token. Always returns a `Me`, even ' +
|
|
10
|
+
'before onboarding: a fresh account comes back with only `userId` set.',
|
|
11
|
+
resolve: (_root, _args, { userId }) => UserQuery.me(userId),
|
|
12
|
+
}),
|
|
13
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { builder } from '~/domain/shared/graphql/builder'
|
|
2
|
+
import type { MeView } from '~/domain/user/types'
|
|
3
|
+
|
|
4
|
+
export const MeType = builder.objectRef<MeView>('Me').implement({
|
|
5
|
+
description:
|
|
6
|
+
'The signed-in user: their identity and onboarding state.\n\n' +
|
|
7
|
+
'Always resolvable from the auth token, so `userId` is always present. Every other ' +
|
|
8
|
+
'field is null until the onboarding completes, which lets the app route a fresh ' +
|
|
9
|
+
'account into onboarding.',
|
|
10
|
+
fields: (t) => ({
|
|
11
|
+
userId: t.expose('userId', {
|
|
12
|
+
type: 'UserId',
|
|
13
|
+
description: 'The Firebase Auth user id, also the id of the profile document.',
|
|
14
|
+
}),
|
|
15
|
+
firstName: t.expose('firstName', {
|
|
16
|
+
type: 'PersonName',
|
|
17
|
+
nullable: true,
|
|
18
|
+
description: "The user's first name, set during onboarding; null before it is done.",
|
|
19
|
+
}),
|
|
20
|
+
onboardingCompletedAt: t.expose('onboardingCompletedAt', {
|
|
21
|
+
type: 'DateTime',
|
|
22
|
+
nullable: true,
|
|
23
|
+
description: 'When onboarding finished, or null if it never has.',
|
|
24
|
+
}),
|
|
25
|
+
onboardingCompleted: t.boolean({
|
|
26
|
+
description: 'The flag the app routes on: true once onboarding is done.',
|
|
27
|
+
resolve: (me) => me.onboardingCompletedAt != null,
|
|
28
|
+
}),
|
|
29
|
+
isAdmin: t.expose('admin', {
|
|
30
|
+
type: 'Boolean',
|
|
31
|
+
description:
|
|
32
|
+
'Whether this account may open the in-app admin screen. Set by hand on the profile document.',
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { UserId } from '~/domain/shared/types'
|
|
2
|
+
import type { UserProfile } from '~/domain/user/types'
|
|
3
|
+
import { db } from '~/system/firebase'
|
|
4
|
+
import { deleteAuthUser } from '~/system/identity'
|
|
5
|
+
import { evictFromRequestCache, memoizedPerRequest } from '~/system/request-cache'
|
|
6
|
+
import { genericDataConverter } from '~/utils/firestore'
|
|
7
|
+
|
|
8
|
+
const profiles = () => db().collection('users').withConverter(genericDataConverter<UserProfile>())
|
|
9
|
+
|
|
10
|
+
const cacheKey = (userId: UserId) => `user:profile:${userId}`
|
|
11
|
+
|
|
12
|
+
/** A user's profile, at most one: the document id IS the userId. Memoized: the
|
|
13
|
+
* auth gate reads it on every launch, so it must cost a single read per request. */
|
|
14
|
+
export const findProfile = (userId: UserId): Promise<UserProfile | null> =>
|
|
15
|
+
memoizedPerRequest(cacheKey(userId), async () => {
|
|
16
|
+
const doc = await profiles().doc(userId).get()
|
|
17
|
+
return doc.data() ?? null
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** The names behind a list of ids, in one getAll rather than a lookup per row. */
|
|
21
|
+
export const findProfiles = async (userIds: readonly UserId[]): Promise<UserProfile[]> => {
|
|
22
|
+
if (userIds.length === 0) return []
|
|
23
|
+
const refs = [...new Set(userIds)].map((userId) => profiles().doc(userId))
|
|
24
|
+
const docs = await db().getAll(...refs)
|
|
25
|
+
return docs.flatMap((doc) => {
|
|
26
|
+
const profile = doc.data() as UserProfile | undefined
|
|
27
|
+
return profile ? [profile] : []
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every profile, for a scheduled pass: a scan, read once per run. */
|
|
32
|
+
export const findAllProfiles = async (): Promise<UserProfile[]> =>
|
|
33
|
+
(await profiles().get()).docs.map((doc) => doc.data())
|
|
34
|
+
|
|
35
|
+
/** How many accounts have a profile: one billed aggregate, never a scan. */
|
|
36
|
+
export const countProfiles = async (): Promise<number> =>
|
|
37
|
+
(await profiles().count().get()).data().count
|
|
38
|
+
|
|
39
|
+
export const countProfilesSince = async (since: Date): Promise<number> =>
|
|
40
|
+
(await profiles().where('onboardingCompletedAt', '>=', since).count().get()).data().count
|
|
41
|
+
|
|
42
|
+
export const saveProfile = async (profile: UserProfile): Promise<UserProfile> => {
|
|
43
|
+
await profiles().doc(profile.userId).set(profile)
|
|
44
|
+
// The onboarding read the profile to know whether it was the first time, then
|
|
45
|
+
// answers with what it just wrote: the absence it saw must not be read back.
|
|
46
|
+
evictFromRequestCache(cacheKey(profile.userId))
|
|
47
|
+
return profile
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Idempotent: an already-absent profile is a no-op, so a retried deletion never errors. */
|
|
51
|
+
export const removeProfile = async (userId: UserId): Promise<void> => {
|
|
52
|
+
await profiles().doc(userId).delete()
|
|
53
|
+
evictFromRequestCache(cacheKey(userId))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The one place the auth provider is touched: external access stays in the storage layer. */
|
|
57
|
+
export const removeAuthUser = async (userId: UserId): Promise<void> => {
|
|
58
|
+
await deleteAuthUser(userId)
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import { graphql } from 'graphql'
|
|
3
|
+
import type { UserId } from '~/domain/shared/types'
|
|
4
|
+
import { fakeDb, resetFakeFirestore } from '~/test/fake-firestore'
|
|
5
|
+
|
|
6
|
+
mock.module('~/system/firebase', () => ({ db: fakeDb }))
|
|
7
|
+
mock.module('~/system/identity', () => ({ deleteAuthUser: async () => {} }))
|
|
8
|
+
|
|
9
|
+
const { schema } = await import('~/domain/shared/graphql/schema')
|
|
10
|
+
|
|
11
|
+
const run = (source: string, userId = 'u1') =>
|
|
12
|
+
graphql({ schema, source, contextValue: { event: undefined, userId: userId as UserId } })
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetFakeFirestore()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('the user API contract', () => {
|
|
19
|
+
test('me answers the id alone before onboarding, then the profile after it', async () => {
|
|
20
|
+
const before = await run('{ me { userId firstName onboardingCompleted } }')
|
|
21
|
+
expect(before.errors).toBeUndefined()
|
|
22
|
+
expect(before.data?.me).toEqual({ userId: 'u1', firstName: null, onboardingCompleted: false })
|
|
23
|
+
|
|
24
|
+
const done = await run(
|
|
25
|
+
'mutation { completeOnboarding(input: { firstName: "Ada" }) { firstName onboardingCompleted } }',
|
|
26
|
+
)
|
|
27
|
+
expect(done.data?.completeOnboarding).toEqual({ firstName: 'Ada', onboardingCompleted: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('an invalid name is a BAD_USER_INPUT, not a 500', async () => {
|
|
31
|
+
const result = await run('mutation { completeOnboarding(input: { firstName: "" }) { userId } }')
|
|
32
|
+
expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('deleteAccount answers true and leaves no profile', async () => {
|
|
36
|
+
await run('mutation { completeOnboarding(input: { firstName: "Ada" }) { userId } }')
|
|
37
|
+
const deleted = await run('mutation { deleteAccount }')
|
|
38
|
+
expect(deleted.data?.deleteAccount).toBe(true)
|
|
39
|
+
const after = await run('{ me { onboardingCompleted } }')
|
|
40
|
+
expect(after.data?.me).toEqual({ onboardingCompleted: false })
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import type { PersonName, UserId } from '~/domain/shared/types'
|
|
3
|
+
import type { UserProfile } from '~/domain/user/types'
|
|
4
|
+
import { fakeDb, resetFakeFirestore } from '~/test/fake-firestore'
|
|
5
|
+
|
|
6
|
+
mock.module('~/system/firebase', () => ({ db: fakeDb }))
|
|
7
|
+
|
|
8
|
+
const UserQuery = await import('~/domain/user/query')
|
|
9
|
+
const UserCommand = await import('~/domain/user/command')
|
|
10
|
+
|
|
11
|
+
const user = (id: string) => id as UserId
|
|
12
|
+
|
|
13
|
+
let fake = resetFakeFirestore()
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fake = resetFakeFirestore()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('UserQuery.me', () => {
|
|
20
|
+
test('returns nulls when no profile exists, so the app routes to onboarding', async () => {
|
|
21
|
+
const me = await UserQuery.me(user('u1'))
|
|
22
|
+
expect(me.userId).toBe(user('u1'))
|
|
23
|
+
expect(me.firstName).toBeUndefined()
|
|
24
|
+
expect(me.onboardingCompletedAt).toBeUndefined()
|
|
25
|
+
expect(fake.docReads).toBe(1)
|
|
26
|
+
expect(fake.queryReads).toBe(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('returns the first name and the timestamp once a profile is saved', async () => {
|
|
30
|
+
const profile: UserProfile = {
|
|
31
|
+
userId: user('u1'),
|
|
32
|
+
firstName: 'Ada' as PersonName,
|
|
33
|
+
onboardingCompletedAt: new Date('2026-01-01'),
|
|
34
|
+
}
|
|
35
|
+
fake.seed('users', 'u1', profile)
|
|
36
|
+
const me = await UserQuery.me(user('u1'))
|
|
37
|
+
expect(me.firstName as string).toBe('Ada')
|
|
38
|
+
expect(me.onboardingCompletedAt).toEqual(new Date('2026-01-01'))
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('UserCommand.completeOnboarding', () => {
|
|
43
|
+
test('writes the profile and the same request reads it back', async () => {
|
|
44
|
+
await UserCommand.completeOnboarding(user('u1'), 'Ada' as PersonName)
|
|
45
|
+
const me = await UserQuery.me(user('u1'))
|
|
46
|
+
expect(me.firstName as string).toBe('Ada')
|
|
47
|
+
expect(me.onboardingCompletedAt).toBeInstanceOf(Date)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Count } from '~/domain/shared/primitives'
|
|
2
|
+
import type { Count as CountType, UserId } from '~/domain/shared/types'
|
|
3
|
+
import * as repository from '~/domain/user/infrastructure/repository'
|
|
4
|
+
import type { MeView } from '~/domain/user/types'
|
|
5
|
+
|
|
6
|
+
/** The signed-in user's identity and onboarding state. Nulls when no profile
|
|
7
|
+
* exists yet: the app routes to onboarding on a missing timestamp. */
|
|
8
|
+
export const me = async (userId: UserId): Promise<MeView> => {
|
|
9
|
+
const profile = await repository.findProfile(userId)
|
|
10
|
+
return {
|
|
11
|
+
userId,
|
|
12
|
+
firstName: profile?.firstName,
|
|
13
|
+
onboardingCompletedAt: profile?.onboardingCompletedAt,
|
|
14
|
+
admin: profile?.admin === true,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first names behind a set of ids, for a list that names people. */
|
|
19
|
+
export const namesOf = async (userIds: readonly UserId[]): Promise<Map<UserId, string>> =>
|
|
20
|
+
new Map(
|
|
21
|
+
(await repository.findProfiles(userIds)).map((profile) => [
|
|
22
|
+
profile.userId,
|
|
23
|
+
String(profile.firstName),
|
|
24
|
+
]),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
/** Every account, for the scheduled passes that look after each user. */
|
|
28
|
+
export const allIds = async (): Promise<UserId[]> =>
|
|
29
|
+
(await repository.findAllProfiles()).map((profile) => profile.userId)
|
|
30
|
+
|
|
31
|
+
export const total = async (): Promise<CountType> => Count(await repository.countProfiles())
|
|
32
|
+
|
|
33
|
+
export const joinedSince = async (since: Date): Promise<CountType> =>
|
|
34
|
+
Count(await repository.countProfilesSince(since))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PersonName, UserId } from '~/domain/shared/types'
|
|
2
|
+
|
|
3
|
+
/** A user's profile, written once onboarding completes. The document id IS the
|
|
4
|
+
* userId, so a user has at most one profile. `admin` opens the in-app admin
|
|
5
|
+
* screen; it is only ever set by hand in the Firestore console, never by code. */
|
|
6
|
+
export type UserProfile = {
|
|
7
|
+
userId: UserId
|
|
8
|
+
firstName: PersonName
|
|
9
|
+
onboardingCompletedAt: Date
|
|
10
|
+
admin?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The signed-in user's identity and onboarding state. Every field but the id is
|
|
14
|
+
* optional so the app can route to onboarding when no profile exists yet. */
|
|
15
|
+
export type MeView = {
|
|
16
|
+
userId: UserId
|
|
17
|
+
firstName?: PersonName
|
|
18
|
+
onboardingCompletedAt?: Date
|
|
19
|
+
admin: boolean
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UserId } from '~/domain/shared/types'
|
|
2
|
+
import * as UserCommand from '~/domain/user/command'
|
|
3
|
+
|
|
4
|
+
/** Delete the account and every trace of it. Each step reaches a domain through
|
|
5
|
+
* its public command surface, never a repository. Idempotent, so a retry after a
|
|
6
|
+
* partial failure is safe, and the order matters: every per-user collection
|
|
7
|
+
* first, in parallel; then the profile; then the Firebase Auth user LAST. If an
|
|
8
|
+
* earlier step fails the user is still signed in and can retry, whereas deleting
|
|
9
|
+
* auth first would strand the data with nobody able to reach it.
|
|
10
|
+
*
|
|
11
|
+
* Add each new domain's `deleteAllForUser` to the parallel block as it ships. */
|
|
12
|
+
export const deleteAccount = async (userId: UserId) => {
|
|
13
|
+
await Promise.all([
|
|
14
|
+
// <domain>Command.deleteAllForUser(userId),
|
|
15
|
+
])
|
|
16
|
+
await UserCommand.deleteProfile(userId)
|
|
17
|
+
await UserCommand.deleteAuthAccount(userId)
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getAuth } from 'firebase-admin/auth'
|
|
2
|
+
import { UserId } from '~/domain/shared/primitives'
|
|
3
|
+
// Side-effect import: ensures firebase-admin is initialized before verifyIdToken.
|
|
4
|
+
import '~/system/firebase'
|
|
5
|
+
import { config } from '~/system/config'
|
|
6
|
+
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
const path = event.path ?? ''
|
|
9
|
+
|
|
10
|
+
// Admin endpoints: a separate token, never a Firebase user. Cloud Scheduler
|
|
11
|
+
// and the Deploy workflow call them.
|
|
12
|
+
if (path.startsWith('/admin/')) {
|
|
13
|
+
const auth = getHeader(event, 'authorization')
|
|
14
|
+
const adminToken = config().adminToken
|
|
15
|
+
if (!adminToken || auth !== `Bearer ${adminToken}`)
|
|
16
|
+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Public app configuration: read by the update gate, possibly signed out.
|
|
21
|
+
if (path === '/app-config' || path.startsWith('/app-config?')) return
|
|
22
|
+
|
|
23
|
+
// Everything else (incl. /graphql): require a valid Firebase ID token.
|
|
24
|
+
const auth = getHeader(event, 'authorization')
|
|
25
|
+
|
|
26
|
+
// Dev-only: let a browser reach Apollo Sandbox without a Firebase token.
|
|
27
|
+
// import.meta.dev is compile-time, so this block is tree-shaken out of prod builds.
|
|
28
|
+
if (import.meta.dev && !auth) {
|
|
29
|
+
const devUserId = config().devUserId
|
|
30
|
+
if (!devUserId)
|
|
31
|
+
throw createError({
|
|
32
|
+
statusCode: 401,
|
|
33
|
+
statusMessage: 'Dev auth bypass needs NITRO_DEV_USER_ID in .env (a Firebase UID)',
|
|
34
|
+
})
|
|
35
|
+
event.context.userId = UserId(devUserId)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const token = auth?.startsWith('Bearer ') ? auth.slice(7) : null
|
|
40
|
+
if (!token) throw createError({ statusCode: 401, statusMessage: 'Missing bearer token' })
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const decoded = await getAuth().verifyIdToken(token)
|
|
44
|
+
event.context.userId = UserId(decoded.uid)
|
|
45
|
+
} catch {
|
|
46
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid token' })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
declare module 'h3' {
|
|
51
|
+
interface H3EventContext {
|
|
52
|
+
userId?: ReturnType<typeof UserId>
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as Sentry from '@sentry/node'
|
|
2
|
+
import { config } from '~/system/config/index'
|
|
3
|
+
import { flushReports } from '~/system/logger'
|
|
4
|
+
|
|
5
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
6
|
+
// Mirrors the iOS `#if DEBUG` gate in the app: only the deployed
|
|
7
|
+
// build reports, so a local run never pollutes the production project.
|
|
8
|
+
// import.meta.dev is compile-time, so the guard costs nothing in the prod bundle
|
|
9
|
+
// and everything below it is tree-shaken out of dev builds.
|
|
10
|
+
if (import.meta.dev) return
|
|
11
|
+
|
|
12
|
+
const { sentryDsn, sentryRelease } = config()
|
|
13
|
+
if (!sentryDsn) return
|
|
14
|
+
|
|
15
|
+
Sentry.init({
|
|
16
|
+
dsn: sentryDsn,
|
|
17
|
+
environment: 'production',
|
|
18
|
+
release: sentryRelease,
|
|
19
|
+
// Only performance traces are sampled — errors are always sent in full, and
|
|
20
|
+
// each one carries its trace id, so an app error and the resolver that threw
|
|
21
|
+
// still line up in the same trace at a tenth of the transaction volume.
|
|
22
|
+
tracesSampleRate: 0.1,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const originalHandler = nitroApp.h3App.handler
|
|
26
|
+
nitroApp.h3App.handler = ((event) =>
|
|
27
|
+
// The app sends its own trace headers, so the request continues the trace the
|
|
28
|
+
// tap on screen started instead of opening a fresh one. That is what puts the
|
|
29
|
+
// iOS error and the backend error it caused side by side in Sentry.
|
|
30
|
+
Sentry.continueTrace(
|
|
31
|
+
{
|
|
32
|
+
sentryTrace: getHeader(event, 'sentry-trace'),
|
|
33
|
+
baggage: getHeader(event, 'baggage'),
|
|
34
|
+
},
|
|
35
|
+
() =>
|
|
36
|
+
Sentry.startSpan(
|
|
37
|
+
{ name: `${event.method} ${event.path}`, op: 'http.server' },
|
|
38
|
+
async (span) => {
|
|
39
|
+
try {
|
|
40
|
+
const result = await originalHandler(event)
|
|
41
|
+
const route = event.context.matchedRoute?.path
|
|
42
|
+
if (route) span.updateName(`${event.method} ${route}`)
|
|
43
|
+
return result
|
|
44
|
+
} finally {
|
|
45
|
+
// What the loggers reported while serving — a model call that
|
|
46
|
+
// failed and was recovered from — leaves before the response
|
|
47
|
+
// does, since Cloud Functions throttles the CPU right after.
|
|
48
|
+
await flushReports()
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
),
|
|
52
|
+
)) as typeof originalHandler
|
|
53
|
+
|
|
54
|
+
nitroApp.hooks.hook('error', async (error, { event }) => {
|
|
55
|
+
const statusCode = (error as { statusCode?: number }).statusCode
|
|
56
|
+
if (statusCode && statusCode >= 400 && statusCode < 500) return
|
|
57
|
+
|
|
58
|
+
// The uid rides on the event rather than a global scope: the function serves
|
|
59
|
+
// up to 80 requests at once, so a shared scope would credit an error to
|
|
60
|
+
// whoever happened to be in flight. It turns "an error" into "how many
|
|
61
|
+
// accounts are affected", and points straight at the documents to inspect.
|
|
62
|
+
const userId = event?.context.userId
|
|
63
|
+
Sentry.captureException(error, {
|
|
64
|
+
user: userId ? { id: userId } : undefined,
|
|
65
|
+
extra: {
|
|
66
|
+
path: event?.path,
|
|
67
|
+
method: event?.method,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
// Cloud Functions gen2 throttles the CPU once the response is sent —
|
|
71
|
+
// flush now or the event may never leave the instance.
|
|
72
|
+
await Sentry.flush(2000)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createLogger } from '~/system/logger'
|
|
2
|
+
|
|
3
|
+
const logger = createLogger('request')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Logs one line per request with its duration, in dev only.
|
|
7
|
+
*
|
|
8
|
+
* The end-to-end gate had the app stuck on a loading state with no way to tell
|
|
9
|
+
* whether the request never left, never came back, or came back with an error:
|
|
10
|
+
* `nitro dev` prints nothing per request, so the server log showed a clean boot
|
|
11
|
+
* either way. `import.meta.dev` is compile-time, so none of this is in the
|
|
12
|
+
* deployed bundle.
|
|
13
|
+
*/
|
|
14
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
15
|
+
if (!import.meta.dev) return
|
|
16
|
+
|
|
17
|
+
const started = new WeakMap<object, number>()
|
|
18
|
+
|
|
19
|
+
nitroApp.hooks.hook('request', (event) => {
|
|
20
|
+
started.set(event, Date.now())
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
nitroApp.hooks.hook('afterResponse', (event) => {
|
|
24
|
+
const at = started.get(event)
|
|
25
|
+
const elapsed = at ? `${Date.now() - at}ms` : '?'
|
|
26
|
+
logger.info(`${event.method} ${event.path} → ${event.node.res.statusCode} in ${elapsed}`)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ApolloServer, type ApolloServerPlugin } from '@apollo/server'
|
|
2
|
+
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'
|
|
3
|
+
import * as Sentry from '@sentry/node'
|
|
4
|
+
import { GraphQLError } from 'graphql'
|
|
5
|
+
import type { GraphQLContext } from '~/domain/shared/graphql/builder'
|
|
6
|
+
import { schema } from '~/domain/shared/graphql/schema'
|
|
7
|
+
import { createLogger } from '~/system/logger'
|
|
8
|
+
import { startReadMeter } from '~/system/read-meter'
|
|
9
|
+
import { setApollo } from '~/utils/apollo'
|
|
10
|
+
|
|
11
|
+
const logger = createLogger('graphql')
|
|
12
|
+
|
|
13
|
+
// Apollo answers resolver exceptions with HTTP 200 + errors[], so Nitro's
|
|
14
|
+
// `error` hook never sees them. Capture unexpected ones (anything that is not
|
|
15
|
+
// a deliberate GraphQLError thrown by a resolver or scalar) here instead.
|
|
16
|
+
const sentryErrorCapture = {
|
|
17
|
+
requestDidStart: async () => ({
|
|
18
|
+
didEncounterErrors: async ({ errors }: { errors: readonly GraphQLError[] }) => {
|
|
19
|
+
const unexpected = errors
|
|
20
|
+
.map((error) => error.originalError)
|
|
21
|
+
.filter((cause): cause is Error => cause !== undefined && !(cause instanceof GraphQLError))
|
|
22
|
+
if (unexpected.length === 0) return
|
|
23
|
+
for (const cause of unexpected) Sentry.captureException(cause)
|
|
24
|
+
// Cloud Functions gen2 throttles the CPU once the response is sent —
|
|
25
|
+
// flush now or the event may never leave the instance.
|
|
26
|
+
await Sentry.flush(2000)
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One line per operation, to find the slow screens: which operation, how long
|
|
32
|
+
// it took, and the Firestore reads it made. Info level, so never sent to Sentry;
|
|
33
|
+
// in a built bundle the context keys land under `jsonPayload`, where Cloud
|
|
34
|
+
// Logging can sum them per operation.
|
|
35
|
+
const operationMetrics: ApolloServerPlugin<GraphQLContext> = {
|
|
36
|
+
requestDidStart: async ({ contextValue }) => {
|
|
37
|
+
const started = performance.now()
|
|
38
|
+
const reads = startReadMeter(contextValue.event.context)
|
|
39
|
+
return {
|
|
40
|
+
willSendResponse: async ({ operationName, operation, errors }) => {
|
|
41
|
+
logger.info('operation served', {
|
|
42
|
+
operation: operationName ?? 'anonymous',
|
|
43
|
+
type: operation?.operation,
|
|
44
|
+
durationMs: Math.round(performance.now() - started),
|
|
45
|
+
...reads,
|
|
46
|
+
reads: reads.docReads + reads.queryDocs,
|
|
47
|
+
errors: errors?.length ?? 0,
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default defineNitroPlugin(async () => {
|
|
55
|
+
const plugins: ApolloServerPlugin<GraphQLContext>[] = [sentryErrorCapture, operationMetrics]
|
|
56
|
+
// Dev-only: serve the embedded Apollo Sandbox on GET /graphql (Accept: text/html).
|
|
57
|
+
if (import.meta.dev) plugins.push(ApolloServerPluginLandingPageLocalDefault({ embed: true }))
|
|
58
|
+
const apollo = new ApolloServer<GraphQLContext>({
|
|
59
|
+
schema,
|
|
60
|
+
introspection: true,
|
|
61
|
+
plugins,
|
|
62
|
+
// Apollo is externalized in the bundle, so it would otherwise decide this from the
|
|
63
|
+
// runtime NODE_ENV, which nothing in infra/function.tf sets. Pin it to the
|
|
64
|
+
// compile-time flag so prod never returns stack traces to a client.
|
|
65
|
+
includeStacktraceInErrorResponses: import.meta.dev,
|
|
66
|
+
})
|
|
67
|
+
await apollo.start()
|
|
68
|
+
setApollo(apollo)
|
|
69
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createLogger } from '~/system/logger'
|
|
2
|
+
|
|
3
|
+
const logger = createLogger('boot')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Logs, once per instance, how long the process took to load the bundle.
|
|
7
|
+
*
|
|
8
|
+
* Cloud Run's `container/startup_latencies` measured about 6 s per cold start
|
|
9
|
+
* on 2026-10-15, but it lumps Google's share (scheduling the instance, starting
|
|
10
|
+
* Node and the Functions Framework) with ours (importing the bundle). Plugins run
|
|
11
|
+
* once every module is loaded, so the process's uptime here is our share; the
|
|
12
|
+
* metric minus this figure is Google's. Scale-to-zero is deliberate, so this is
|
|
13
|
+
* for watching the trend, not for keeping instances warm.
|
|
14
|
+
*/
|
|
15
|
+
export default defineNitroPlugin(() => {
|
|
16
|
+
if (import.meta.dev) return
|
|
17
|
+
logger.info('server ready', {
|
|
18
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
19
|
+
revision: process.env.K_REVISION,
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { migrations } from '~/system/migration/migrations'
|
|
2
|
+
import { runMigrations } from '~/system/migration/runner'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const result = await runMigrations(migrations)
|
|
6
|
+
// A failed migration must fail the CI deploy step (curl -fsS gates on the status).
|
|
7
|
+
if (result.outcome === 'failed') setResponseStatus(event, 500)
|
|
8
|
+
return result
|
|
9
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { APP_STORE_URL, MINIMUM_SUPPORTED_IOS_BUILD } from '~/system/app-support'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public client configuration, read by the iOS app at launch (before any
|
|
5
|
+
* sign-in, so this route is whitelisted in the auth middleware). Drives the
|
|
6
|
+
* force-update gate: builds below `minimumSupportedIOSBuild` are blocked.
|
|
7
|
+
*/
|
|
8
|
+
export default defineEventHandler((event) => {
|
|
9
|
+
// A cached floor would defeat the foreground re-check on already-open apps.
|
|
10
|
+
setResponseHeader(event, 'cache-control', 'no-store')
|
|
11
|
+
return {
|
|
12
|
+
minimumSupportedIOSBuild: MINIMUM_SUPPORTED_IOS_BUILD,
|
|
13
|
+
appStoreUrl: APP_STORE_URL,
|
|
14
|
+
}
|
|
15
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ApolloServer, HeaderMap } from '@apollo/server'
|
|
2
|
+
import type { UserId } from '~/domain/shared/types'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const apollo = useApollo()
|
|
6
|
+
const userId = (event.context as { userId?: UserId }).userId
|
|
7
|
+
if (!userId) throw createError({ statusCode: 401, statusMessage: 'Missing user context' })
|
|
8
|
+
// No loader set: a book carries its series name and volume number, so grouping
|
|
9
|
+
// a library reads no catalogue document. The request cache in the repositories
|
|
10
|
+
// covers the rest.
|
|
11
|
+
const context = async () => ({ event, userId })
|
|
12
|
+
|
|
13
|
+
const headerMap = new HeaderMap()
|
|
14
|
+
for (const [key, value] of Object.entries(getHeaders(event))) {
|
|
15
|
+
if (value !== undefined) headerMap.set(key, value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (event.method === 'GET') {
|
|
19
|
+
const query = getQuery(event)
|
|
20
|
+
const search = new URLSearchParams()
|
|
21
|
+
for (const [key, value] of Object.entries(query)) {
|
|
22
|
+
if (value !== undefined) search.set(key, String(value))
|
|
23
|
+
}
|
|
24
|
+
const response = await apollo.executeHTTPGraphQLRequest({
|
|
25
|
+
httpGraphQLRequest: {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
headers: headerMap,
|
|
28
|
+
body: undefined,
|
|
29
|
+
search: search.toString(),
|
|
30
|
+
},
|
|
31
|
+
context,
|
|
32
|
+
})
|
|
33
|
+
return sendApolloResponse(event, response)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const body = await readBody(event)
|
|
37
|
+
const response = await apollo.executeHTTPGraphQLRequest({
|
|
38
|
+
httpGraphQLRequest: { method: 'POST', headers: headerMap, body, search: '' },
|
|
39
|
+
context,
|
|
40
|
+
})
|
|
41
|
+
return sendApolloResponse(event, response)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function sendApolloResponse(
|
|
45
|
+
event: Parameters<typeof setResponseStatus>[0],
|
|
46
|
+
response: Awaited<ReturnType<ApolloServer['executeHTTPGraphQLRequest']>>,
|
|
47
|
+
) {
|
|
48
|
+
setResponseStatus(event, response.status || 200)
|
|
49
|
+
for (const [key, value] of response.headers) setResponseHeader(event, key, value)
|
|
50
|
+
if (response.body.kind === 'complete') return response.body.string
|
|
51
|
+
throw createError({ statusCode: 500, statusMessage: 'Chunked responses not supported' })
|
|
52
|
+
}
|