@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,60 @@
|
|
|
1
|
+
import { ChangelogVersion } from '~/domain/changelog/primitives'
|
|
2
|
+
import type { ChangelogEntry } from '~/domain/changelog/types'
|
|
3
|
+
|
|
4
|
+
const headingPattern = /^##\s+(.+?)\s*$/
|
|
5
|
+
const bulletPattern = /^[-*]\s+(.+?)\s*$/
|
|
6
|
+
// Heading forms: "1.1 (2026.07.15)" (version + date), a bare "2026.07.15" (legacy),
|
|
7
|
+
// or a plain label such as "Unreleased" (no date).
|
|
8
|
+
const versionedPattern = /^(.+?)\s*\((\d{4})\.(\d{2})\.(\d{2})\)$/
|
|
9
|
+
const datedPattern = /^(\d{4})\.(\d{2})\.(\d{2})$/
|
|
10
|
+
|
|
11
|
+
const toUtcDate = (year: string, month: string, day: string): Date =>
|
|
12
|
+
new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
|
13
|
+
|
|
14
|
+
const parseHeading = (line: string): { version: string; date: Date | null } | null => {
|
|
15
|
+
const match = headingPattern.exec(line.trim())
|
|
16
|
+
if (!match) return null
|
|
17
|
+
const text = match[1].trim()
|
|
18
|
+
const versioned = versionedPattern.exec(text)
|
|
19
|
+
if (versioned)
|
|
20
|
+
return {
|
|
21
|
+
version: versioned[1].trim(),
|
|
22
|
+
date: toUtcDate(versioned[2], versioned[3], versioned[4]),
|
|
23
|
+
}
|
|
24
|
+
const dated = datedPattern.exec(text)
|
|
25
|
+
if (dated) return { version: text, date: toUtcDate(dated[1], dated[2], dated[3]) }
|
|
26
|
+
return { version: text, date: null }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isHeading = (line: string) => line.trimStart().startsWith('## ')
|
|
30
|
+
|
|
31
|
+
export const parseChangelog = (markdown: string): ChangelogEntry[] => {
|
|
32
|
+
const entries: ChangelogEntry[] = []
|
|
33
|
+
const lines = markdown.split(/\r?\n/)
|
|
34
|
+
|
|
35
|
+
let current: ChangelogEntry | null = null
|
|
36
|
+
|
|
37
|
+
for (const raw of lines) {
|
|
38
|
+
const line = raw.trimEnd()
|
|
39
|
+
if (isHeading(line)) {
|
|
40
|
+
if (current) entries.push(current)
|
|
41
|
+
const heading = parseHeading(line)
|
|
42
|
+
if (!heading) {
|
|
43
|
+
current = null
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
current = {
|
|
47
|
+
version: ChangelogVersion(heading.version),
|
|
48
|
+
date: heading.date,
|
|
49
|
+
notes: [],
|
|
50
|
+
}
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (!current) continue
|
|
54
|
+
const bullet = bulletPattern.exec(line)
|
|
55
|
+
if (bullet) current.notes.push(bullet[1])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (current) entries.push(current)
|
|
59
|
+
return entries
|
|
60
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { parseChangelog } from '~/domain/changelog/business-rules'
|
|
3
|
+
import { ChangelogVersion } from '~/domain/changelog/primitives'
|
|
4
|
+
|
|
5
|
+
describe('parseChangelog', () => {
|
|
6
|
+
test('returns an empty list for an empty document', () => {
|
|
7
|
+
expect(parseChangelog('')).toEqual([])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('returns an empty list when no version heading exists', () => {
|
|
11
|
+
expect(parseChangelog('# Changelog\n\nIntro paragraph.\n')).toEqual([])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('parses a versioned heading into a version and a date', () => {
|
|
15
|
+
const md = ['# Changelog', '', '## 1.1 (2026.04.26)', '- First note', '- Second note', ''].join(
|
|
16
|
+
'\n',
|
|
17
|
+
)
|
|
18
|
+
const result = parseChangelog(md)
|
|
19
|
+
expect(result).toHaveLength(1)
|
|
20
|
+
expect(result[0].version).toBe(ChangelogVersion('1.1'))
|
|
21
|
+
expect(result[0].date?.toISOString()).toBe('2026-04-26T00:00:00.000Z')
|
|
22
|
+
expect(result[0].notes).toEqual(['First note', 'Second note'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('parses a legacy bare-date heading (version equals the date)', () => {
|
|
26
|
+
const md = ['# Changelog', '', '## 2026.04.26', '- Note', ''].join('\n')
|
|
27
|
+
const result = parseChangelog(md)
|
|
28
|
+
expect(result).toHaveLength(1)
|
|
29
|
+
expect(result[0].version).toBe(ChangelogVersion('2026.04.26'))
|
|
30
|
+
expect(result[0].date?.toISOString()).toBe('2026-04-26T00:00:00.000Z')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('parses an Unreleased section with a null date', () => {
|
|
34
|
+
const md = '# Changelog\n\n## Unreleased\n- Pending note\n'
|
|
35
|
+
const result = parseChangelog(md)
|
|
36
|
+
expect(result).toHaveLength(1)
|
|
37
|
+
expect(result[0].version).toBe(ChangelogVersion('Unreleased'))
|
|
38
|
+
expect(result[0].date).toBeNull()
|
|
39
|
+
expect(result[0].notes).toEqual(['Pending note'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('preserves the order of versions as written', () => {
|
|
43
|
+
const md = [
|
|
44
|
+
'# Changelog',
|
|
45
|
+
'',
|
|
46
|
+
'## Unreleased',
|
|
47
|
+
'- A',
|
|
48
|
+
'',
|
|
49
|
+
'## 1.1 (2026.04.26)',
|
|
50
|
+
'- B',
|
|
51
|
+
'',
|
|
52
|
+
'## 1.0 (2026.04.20)',
|
|
53
|
+
'- C',
|
|
54
|
+
].join('\n')
|
|
55
|
+
const result = parseChangelog(md)
|
|
56
|
+
expect(result.map((entry) => entry.version)).toEqual([
|
|
57
|
+
ChangelogVersion('Unreleased'),
|
|
58
|
+
ChangelogVersion('1.1'),
|
|
59
|
+
ChangelogVersion('1.0'),
|
|
60
|
+
])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('accepts both dash and asterisk bullets', () => {
|
|
64
|
+
const md = '## 2026.04.26\n- Dash note\n* Asterisk note\n'
|
|
65
|
+
expect(parseChangelog(md)[0].notes).toEqual(['Dash note', 'Asterisk note'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('ignores non-bullet lines under a version heading', () => {
|
|
69
|
+
const md = '## 2026.04.26\nSome paragraph.\n- Real note\nAnother paragraph.\n'
|
|
70
|
+
expect(parseChangelog(md)[0].notes).toEqual(['Real note'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('ignores the level-1 title heading', () => {
|
|
74
|
+
const md = '# Changelog\n\n## 2026.04.26\n- Note\n'
|
|
75
|
+
expect(parseChangelog(md)).toHaveLength(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('rejects malformed dates by rejecting the version itself', () => {
|
|
79
|
+
expect(() => parseChangelog('## 2026/04/26\n- Note\n')).toThrow()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('handles CRLF line endings', () => {
|
|
83
|
+
const md = '# Changelog\r\n\r\n## 2026.04.26\r\n- Note\r\n'
|
|
84
|
+
const result = parseChangelog(md)
|
|
85
|
+
expect(result).toHaveLength(1)
|
|
86
|
+
expect(result[0].notes).toEqual(['Note'])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as ChangelogQuery from '~/domain/changelog/query'
|
|
2
|
+
import { builder } from '~/domain/shared/graphql/builder'
|
|
3
|
+
import { languageFrom } from '~/domain/shared/language'
|
|
4
|
+
import { ChangelogEntryType } from './types'
|
|
5
|
+
|
|
6
|
+
builder.queryField('changelog', (t) =>
|
|
7
|
+
t.field({
|
|
8
|
+
type: [ChangelogEntryType],
|
|
9
|
+
description:
|
|
10
|
+
'Application release notes, one entry per version, most recent first.\n\n' +
|
|
11
|
+
"Served from the published changelog asset in the caller's language (from `Accept-Language`, " +
|
|
12
|
+
'English fallback). Global (not user-scoped); the app uses it to show what changed in each ' +
|
|
13
|
+
'release.',
|
|
14
|
+
resolve: (_root, _args, { event }) =>
|
|
15
|
+
ChangelogQuery.list(languageFrom(event && getHeader(event, 'accept-language'))),
|
|
16
|
+
}),
|
|
17
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChangelogEntry } from '~/domain/changelog/types'
|
|
2
|
+
import { builder } from '~/domain/shared/graphql/builder'
|
|
3
|
+
|
|
4
|
+
export const ChangelogEntryType = builder.objectRef<ChangelogEntry>('ChangelogEntry').implement({
|
|
5
|
+
description:
|
|
6
|
+
'One version section of the application release notes.\n\n' +
|
|
7
|
+
'Parsed from the served changelog document: a version heading, its release date, and the ' +
|
|
8
|
+
'flat list of note lines under it. The app renders it as a row titled by `version` with the ' +
|
|
9
|
+
'formatted `date` on the right and `notes` as bullet points.',
|
|
10
|
+
fields: (t) => ({
|
|
11
|
+
version: t.exposeString('version', {
|
|
12
|
+
description: 'App Store version this section documents (e.g. "1.3")',
|
|
13
|
+
}),
|
|
14
|
+
date: t.expose('date', {
|
|
15
|
+
type: 'DateTime',
|
|
16
|
+
nullable: true,
|
|
17
|
+
description: 'Release date of the version; null for a pending (unreleased) section',
|
|
18
|
+
}),
|
|
19
|
+
notes: t.exposeStringList('notes', {
|
|
20
|
+
description: 'Release note lines for this version, shown as a flat bullet list',
|
|
21
|
+
}),
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { make } from 'ts-brand'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { ChangelogVersion as ChangelogVersionType } from '~/domain/changelog/types'
|
|
4
|
+
|
|
5
|
+
const versionSchema = z
|
|
6
|
+
.string()
|
|
7
|
+
.min(1)
|
|
8
|
+
.refine(
|
|
9
|
+
(v) =>
|
|
10
|
+
v === 'Unreleased' ||
|
|
11
|
+
/^\d+\.\d+(\.\d+)?$/.test(v) || // semantic version, e.g. 1.1
|
|
12
|
+
/^\d{4}\.\d{2}\.\d{2}$/.test(v), // legacy bare date heading
|
|
13
|
+
{ message: 'Expected Unreleased, a semantic version (1.1), or YYYY.MM.DD' },
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
export const ChangelogVersion = (value: unknown) => {
|
|
17
|
+
const v = versionSchema.parse(value)
|
|
18
|
+
return make<ChangelogVersionType>()(v)
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { parseChangelog } from '~/domain/changelog/business-rules'
|
|
2
|
+
import type { Language } from '~/domain/shared/language'
|
|
3
|
+
import { changelogMarkdownByLanguage } from '~/system/changelog-content'
|
|
4
|
+
|
|
5
|
+
/** Served in the caller's language, falling back to English then French for a
|
|
6
|
+
* language whose changelog asset is missing. */
|
|
7
|
+
export const list = async (language: Language) =>
|
|
8
|
+
parseChangelog(
|
|
9
|
+
changelogMarkdownByLanguage[language] ??
|
|
10
|
+
changelogMarkdownByLanguage.en ??
|
|
11
|
+
changelogMarkdownByLanguage.fr ??
|
|
12
|
+
'',
|
|
13
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
// A tiny per-language asset stands in for the generated changelog content, so the
|
|
4
|
+
// test asserts the language dispatch, not the real release notes.
|
|
5
|
+
mock.module('~/system/changelog-content', () => ({
|
|
6
|
+
changelogMarkdownByLanguage: {
|
|
7
|
+
en: '## 1.0 (2026.01.01)\n- English note',
|
|
8
|
+
fr: '## 1.0 (2026.01.01)\n- Note française',
|
|
9
|
+
},
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const ChangelogQuery = await import('~/domain/changelog/query')
|
|
13
|
+
|
|
14
|
+
describe('ChangelogQuery.list', () => {
|
|
15
|
+
test('serves the notes in the requested language', async () => {
|
|
16
|
+
expect((await ChangelogQuery.list('fr'))[0]?.notes).toEqual(['Note française'])
|
|
17
|
+
expect((await ChangelogQuery.list('en'))[0]?.notes).toEqual(['English note'])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import SchemaBuilder from '@pothos/core'
|
|
2
|
+
import { GraphQLScalarType } from 'graphql'
|
|
3
|
+
import type { H3Event } from 'h3'
|
|
4
|
+
import type {
|
|
5
|
+
Count,
|
|
6
|
+
Day,
|
|
7
|
+
Eur,
|
|
8
|
+
Month,
|
|
9
|
+
Percentage,
|
|
10
|
+
PersonName,
|
|
11
|
+
UserId,
|
|
12
|
+
Year,
|
|
13
|
+
} from '~/domain/shared/types'
|
|
14
|
+
|
|
15
|
+
/** What every resolver is handed. No loader map on purpose: a nested field that
|
|
16
|
+
* needs another document is either denormalised on the parent or served by a
|
|
17
|
+
* per-request loader added when that field ships. */
|
|
18
|
+
export type GraphQLContext = {
|
|
19
|
+
event: H3Event
|
|
20
|
+
userId: UserId
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DateTimeScalar = new GraphQLScalarType({
|
|
24
|
+
name: 'DateTime',
|
|
25
|
+
description:
|
|
26
|
+
'A date and time serialized as an ISO 8601 string in UTC. ' +
|
|
27
|
+
'Example: "2026-09-14T09:30:00.000Z".',
|
|
28
|
+
serialize: (value: unknown) => (value instanceof Date ? value.toISOString() : value),
|
|
29
|
+
parseValue: (value: unknown) => new Date(value as string),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const builder = new SchemaBuilder<{
|
|
33
|
+
Context: GraphQLContext
|
|
34
|
+
DefaultFieldNullability: false
|
|
35
|
+
Scalars: {
|
|
36
|
+
DateTime: { Input: Date; Output: Date }
|
|
37
|
+
UserId: { Input: UserId; Output: UserId }
|
|
38
|
+
PersonName: { Input: PersonName; Output: PersonName }
|
|
39
|
+
Count: { Input: Count; Output: Count }
|
|
40
|
+
Percentage: { Input: Percentage; Output: Percentage }
|
|
41
|
+
Eur: { Input: Eur; Output: Eur }
|
|
42
|
+
Year: { Input: Year; Output: Year }
|
|
43
|
+
Month: { Input: Month; Output: Month }
|
|
44
|
+
Day: { Input: Day; Output: Day }
|
|
45
|
+
}
|
|
46
|
+
}>({
|
|
47
|
+
defaultFieldNullability: false,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
builder.addScalarType('DateTime', DateTimeScalar, {})
|
|
51
|
+
builder.queryType({ description: 'Every read of the API. Scoped to the signed-in user.' })
|
|
52
|
+
builder.mutationType({ description: 'Every write of the API. Scoped to the signed-in user.' })
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { GraphQLError } from 'graphql'
|
|
2
|
+
|
|
3
|
+
// Throwing helpers for resolvers: the `never` return type lets them sit in
|
|
4
|
+
// match() arms while the success arms keep the resolver's inferred type.
|
|
5
|
+
export const domainError = (code: string, message: string): never => {
|
|
6
|
+
throw new GraphQLError(message, { extensions: { code } })
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const notFound = (message: string): never => domainError('NOT_FOUND', message)
|
|
10
|
+
|
|
11
|
+
/** The model cannot be asked: our Gemini credit is spent. Its own code, so the
|
|
12
|
+
* app tells the reader to come back later rather than that their book was not
|
|
13
|
+
* found. */
|
|
14
|
+
export const aiUnavailable = (): never =>
|
|
15
|
+
domainError('AI_UNAVAILABLE', 'The AI is unavailable for now; nothing was spent')
|
|
16
|
+
|
|
17
|
+
export const forbidden = (message: string): never => domainError('FORBIDDEN', message)
|
|
18
|
+
|
|
19
|
+
export const badUserInput = (message: string): never => domainError('BAD_USER_INPUT', message)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { GraphQLError } from 'graphql'
|
|
2
|
+
import { ZodError } from 'zod'
|
|
3
|
+
import {
|
|
4
|
+
Count,
|
|
5
|
+
Day,
|
|
6
|
+
Eur,
|
|
7
|
+
Month,
|
|
8
|
+
Percentage,
|
|
9
|
+
PersonName,
|
|
10
|
+
UserId,
|
|
11
|
+
Year,
|
|
12
|
+
} from '~/domain/shared/primitives'
|
|
13
|
+
import { builder } from './builder'
|
|
14
|
+
|
|
15
|
+
// A brand's Zod constructor is the single definition of what the value may be, so
|
|
16
|
+
// the scalar reuses it rather than restating the rule. A rejection becomes a
|
|
17
|
+
// BAD_USER_INPUT the client can show, not a 500.
|
|
18
|
+
export const validatedParse =
|
|
19
|
+
<T>(name: string, parse: (value: unknown) => T) =>
|
|
20
|
+
(value: unknown): T => {
|
|
21
|
+
try {
|
|
22
|
+
return parse(value)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const message =
|
|
25
|
+
error instanceof ZodError
|
|
26
|
+
? error.issues.map(({ message }) => message).join(', ')
|
|
27
|
+
: `Invalid ${name}`
|
|
28
|
+
throw new GraphQLError(`Invalid value for ${name}: ${message}`, {
|
|
29
|
+
extensions: { code: 'BAD_USER_INPUT' },
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const scalar = <T>(name: string, description: string, parse: (value: unknown) => T) =>
|
|
35
|
+
builder.scalarType(
|
|
36
|
+
name as never,
|
|
37
|
+
{
|
|
38
|
+
description,
|
|
39
|
+
serialize: (value: unknown) => value,
|
|
40
|
+
parseValue: validatedParse(name, parse),
|
|
41
|
+
} as never,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
scalar('UserId', 'A Firebase Auth user identifier, an opaque non-empty string.', UserId)
|
|
45
|
+
scalar('PersonName', 'A person the user knows by name, 1 to 200 characters.', PersonName)
|
|
46
|
+
scalar('Count', 'A non-negative whole number of things. Example: 12.', Count)
|
|
47
|
+
scalar('Percentage', 'A percentage from 0 to 100. Example: 42.5.', Percentage)
|
|
48
|
+
scalar('Eur', 'An amount in euros, never negative. Example: 2.99.', Eur)
|
|
49
|
+
scalar('Year', 'A four-digit year. Example: 2026.', Year)
|
|
50
|
+
scalar('Month', 'A calendar month. Example: "2026-10".', Month)
|
|
51
|
+
scalar('Day', 'A calendar day, UTC. Example: "2026-10-04".', Day)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { builder } from './builder'
|
|
2
|
+
|
|
3
|
+
// Custom scalars must be registered before any type that references them.
|
|
4
|
+
import './scalars'
|
|
5
|
+
|
|
6
|
+
// Domain GraphQL layers, imported as they come online. Each domain owns its
|
|
7
|
+
// enums.ts, types.ts, inputs.ts, queries.ts and mutations.ts under
|
|
8
|
+
// infrastructure/graphql/. Order matters when a domain borrows another's scalar
|
|
9
|
+
// or enum: register the lender first.
|
|
10
|
+
|
|
11
|
+
// User (profile, onboarding state, account deletion)
|
|
12
|
+
import '~/domain/user/infrastructure/graphql/types'
|
|
13
|
+
import '~/domain/user/infrastructure/graphql/inputs'
|
|
14
|
+
import '~/domain/user/infrastructure/graphql/queries'
|
|
15
|
+
import '~/domain/user/infrastructure/graphql/mutations'
|
|
16
|
+
|
|
17
|
+
// Changelog (the app's "What's new" list, served from the committed changelogs)
|
|
18
|
+
import '~/domain/changelog/infrastructure/graphql/types'
|
|
19
|
+
import '~/domain/changelog/infrastructure/graphql/queries'
|
|
20
|
+
|
|
21
|
+
export const schema = builder.toSchema()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The languages the app is localized into. French and English ship at launch; the
|
|
2
|
+
// machinery is sized for more, so adding a market is filling in strings rather
|
|
3
|
+
// than reworking anything. Used to pick the language of whatever the backend
|
|
4
|
+
// renders for the client (AI scan text, the served changelog), driven by the
|
|
5
|
+
// request's `Accept-Language`.
|
|
6
|
+
export const SUPPORTED_LANGUAGES = ['fr', 'en'] as const
|
|
7
|
+
|
|
8
|
+
export type Language = (typeof SUPPORTED_LANGUAGES)[number]
|
|
9
|
+
|
|
10
|
+
// Resolve an `Accept-Language` header to a supported language. Reads only the
|
|
11
|
+
// primary subtag of the first listed language (`fr-CH,fr;q=0.9` -> `fr`) and falls
|
|
12
|
+
// back to English for anything unsupported.
|
|
13
|
+
export const languageFrom = (acceptLanguage: string | undefined): Language => {
|
|
14
|
+
const primary = acceptLanguage?.split(',')[0]?.trim().split('-')[0]?.toLowerCase()
|
|
15
|
+
return SUPPORTED_LANGUAGES.includes(primary as Language) ? (primary as Language) : 'en'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The language of a request, read straight off the event.
|
|
19
|
+
*
|
|
20
|
+
* Reads the header itself rather than through `getHeader`, a Nitro auto-import
|
|
21
|
+
* that the schema executed outside a server — the feature tests — does not
|
|
22
|
+
* have. An event with no request, which is what those tests pass, answers
|
|
23
|
+
* English like a request with no header. */
|
|
24
|
+
export const languageOf = (
|
|
25
|
+
event: { node?: { req?: { headers?: { 'accept-language'?: string } } } } | undefined,
|
|
26
|
+
): Language => languageFrom(event?.node?.req?.headers?.['accept-language'])
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A page cut from a list already in memory, by the cursor of the row before
|
|
2
|
+
* it or, for the builds that still send one, by an offset.
|
|
3
|
+
*
|
|
4
|
+
* The cursor names a row rather than counting rows: a row added or removed
|
|
5
|
+
* between two pages shifts an offset, showing a row twice or skipping one,
|
|
6
|
+
* while the row named stays where it is. A cursor naming a row no longer
|
|
7
|
+
* there restarts from the top, as the library's does. `endCursor` is the
|
|
8
|
+
* page's last row, for the next page to start after; null on an empty page. */
|
|
9
|
+
export const pageAfter = <Row>(
|
|
10
|
+
rows: readonly Row[],
|
|
11
|
+
page: { limit: number; after?: string; offset?: number },
|
|
12
|
+
cursorOf: (row: Row) => string,
|
|
13
|
+
): { items: Row[]; hasMore: boolean; endCursor: string | null } => {
|
|
14
|
+
const start =
|
|
15
|
+
page.after !== undefined
|
|
16
|
+
? rows.findIndex((row) => cursorOf(row) === page.after) + 1
|
|
17
|
+
: (page.offset ?? 0)
|
|
18
|
+
const items = rows.slice(start, start + page.limit)
|
|
19
|
+
const last = items.at(-1)
|
|
20
|
+
return {
|
|
21
|
+
items,
|
|
22
|
+
hasMore: start + page.limit < rows.length,
|
|
23
|
+
endCursor: last === undefined ? null : cursorOf(last),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { pageAfter } from './page'
|
|
3
|
+
|
|
4
|
+
const rows = ['a', 'b', 'c', 'd', 'e']
|
|
5
|
+
const cursorOf = (row: string) => row
|
|
6
|
+
|
|
7
|
+
describe('pageAfter', () => {
|
|
8
|
+
test('starts from the top without a cursor', () => {
|
|
9
|
+
expect(pageAfter(rows, { limit: 2 }, cursorOf)).toEqual({
|
|
10
|
+
items: ['a', 'b'],
|
|
11
|
+
hasMore: true,
|
|
12
|
+
endCursor: 'b',
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('starts after the row the cursor names', () => {
|
|
17
|
+
expect(pageAfter(rows, { limit: 2, after: 'b' }, cursorOf)).toEqual({
|
|
18
|
+
items: ['c', 'd'],
|
|
19
|
+
hasMore: true,
|
|
20
|
+
endCursor: 'd',
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('says when the last page is reached', () => {
|
|
25
|
+
expect(pageAfter(rows, { limit: 2, after: 'd' }, cursorOf)).toEqual({
|
|
26
|
+
items: ['e'],
|
|
27
|
+
hasMore: false,
|
|
28
|
+
endCursor: 'e',
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('restarts from the top on a cursor naming a row no longer there', () => {
|
|
33
|
+
expect(pageAfter(rows, { limit: 2, after: 'gone' }, cursorOf).items).toEqual(['a', 'b'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('keeps the offset for the builds that still send one', () => {
|
|
37
|
+
expect(pageAfter(rows, { limit: 2, offset: 3 }, cursorOf).items).toEqual(['d', 'e'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('the cursor wins over an offset', () => {
|
|
41
|
+
expect(pageAfter(rows, { limit: 1, after: 'a', offset: 3 }, cursorOf).items).toEqual(['b'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('an empty page has no cursor', () => {
|
|
45
|
+
expect(pageAfter([], { limit: 2 }, cursorOf)).toEqual({
|
|
46
|
+
items: [],
|
|
47
|
+
hasMore: false,
|
|
48
|
+
endCursor: null,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { make } from 'ts-brand'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type {
|
|
4
|
+
Count as CountType,
|
|
5
|
+
Day as DayType,
|
|
6
|
+
Eur as EurType,
|
|
7
|
+
Month as MonthType,
|
|
8
|
+
Percentage as PercentageType,
|
|
9
|
+
PersonName as PersonNameType,
|
|
10
|
+
UserId as UserIdType,
|
|
11
|
+
Year as YearType,
|
|
12
|
+
} from '~/domain/shared/types'
|
|
13
|
+
|
|
14
|
+
const number = z.preprocess((v) => (typeof v === 'string' ? Number(v) : v), z.number())
|
|
15
|
+
|
|
16
|
+
export const UserId = (value: unknown) => make<UserIdType>()(z.string().min(1).parse(value))
|
|
17
|
+
export const PersonName = (value: unknown) =>
|
|
18
|
+
make<PersonNameType>()(z.string().trim().min(1).max(200).parse(value))
|
|
19
|
+
export const Count = (value: unknown) =>
|
|
20
|
+
make<CountType>()(number.pipe(z.number().int().nonnegative()).parse(value))
|
|
21
|
+
export const Percentage = (value: unknown) =>
|
|
22
|
+
make<PercentageType>()(number.pipe(z.number().min(0).max(100)).parse(value))
|
|
23
|
+
export const Eur = (value: unknown) =>
|
|
24
|
+
make<EurType>()(number.pipe(z.number().nonnegative()).parse(value))
|
|
25
|
+
export const Year = (value: unknown) =>
|
|
26
|
+
make<YearType>()(
|
|
27
|
+
number
|
|
28
|
+
.pipe(
|
|
29
|
+
z
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.min(1000)
|
|
33
|
+
.max(new Date().getUTCFullYear() + 10),
|
|
34
|
+
)
|
|
35
|
+
.parse(value),
|
|
36
|
+
)
|
|
37
|
+
export const Month = (value: unknown) =>
|
|
38
|
+
make<MonthType>()(
|
|
39
|
+
z
|
|
40
|
+
.string()
|
|
41
|
+
.regex(/^\d{4}-\d{2}$/)
|
|
42
|
+
.parse(value),
|
|
43
|
+
)
|
|
44
|
+
export const Day = (value: unknown) =>
|
|
45
|
+
make<DayType>()(
|
|
46
|
+
z
|
|
47
|
+
.string()
|
|
48
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
49
|
+
.parse(value),
|
|
50
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Count, Day, PersonName, UserId, Year } from '~/domain/shared/primitives'
|
|
3
|
+
|
|
4
|
+
describe('shared primitives', () => {
|
|
5
|
+
test('a user id is any non-empty string', () => {
|
|
6
|
+
expect(UserId('abc') as string).toBe('abc')
|
|
7
|
+
expect(() => UserId('')).toThrow()
|
|
8
|
+
})
|
|
9
|
+
test('a person name is trimmed and bounded', () => {
|
|
10
|
+
expect(PersonName(' Ada ') as string).toBe('Ada')
|
|
11
|
+
expect(() => PersonName('x'.repeat(201))).toThrow()
|
|
12
|
+
})
|
|
13
|
+
test('a count accepts a numeric string and refuses a negative', () => {
|
|
14
|
+
expect(Count('3') as number).toBe(3)
|
|
15
|
+
expect(() => Count(-1)).toThrow()
|
|
16
|
+
})
|
|
17
|
+
test('a year stays within a plausible range', () => {
|
|
18
|
+
expect(() => Year(999)).toThrow()
|
|
19
|
+
expect(Year(2026) as number).toBe(2026)
|
|
20
|
+
})
|
|
21
|
+
test('a day is a UTC date string', () => {
|
|
22
|
+
expect(Day('2026-10-04') as string).toBe('2026-10-04')
|
|
23
|
+
expect(() => Day('04/10/2026')).toThrow()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Brand } from 'ts-brand'
|
|
2
|
+
|
|
3
|
+
/** A Firebase Auth user id: the owner named on every private document. */
|
|
4
|
+
export type UserId = Brand<string, 'UserId'>
|
|
5
|
+
/** A person the user knows by name: themselves, asked once during onboarding. */
|
|
6
|
+
export type PersonName = Brand<string, 'PersonName'>
|
|
7
|
+
export type Count = Brand<number, 'Count'>
|
|
8
|
+
export type Percentage = Brand<number, 'Percentage'>
|
|
9
|
+
export type Eur = Brand<number, 'Eur'>
|
|
10
|
+
export type Year = Brand<number, 'Year'>
|
|
11
|
+
/** A calendar month, `"2026-10"`. */
|
|
12
|
+
export type Month = Brand<string, 'Month'>
|
|
13
|
+
/** A calendar day in UTC, `"2026-10-04"`. */
|
|
14
|
+
export type Day = Brand<string, 'Day'>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PersonName, UserId } from '~/domain/shared/types'
|
|
2
|
+
import * as repository from '~/domain/user/infrastructure/repository'
|
|
3
|
+
|
|
4
|
+
/** Persist the profile that marks onboarding done. Reachable again afterwards, so
|
|
5
|
+
* a user can change the name they are addressed by. */
|
|
6
|
+
export const completeOnboarding = (userId: UserId, firstName: PersonName) =>
|
|
7
|
+
repository.saveProfile({ userId, firstName, onboardingCompletedAt: new Date() })
|
|
8
|
+
|
|
9
|
+
export const deleteProfile = async (userId: UserId) => {
|
|
10
|
+
await repository.removeProfile(userId)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The last step of an account deletion, after every trace of its data is gone. */
|
|
14
|
+
export const deleteAuthAccount = async (userId: UserId) => {
|
|
15
|
+
await repository.removeAuthUser(userId)
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { builder } from '~/domain/shared/graphql/builder'
|
|
2
|
+
|
|
3
|
+
export const CompleteOnboardingInput = builder.inputType('CompleteOnboardingInput', {
|
|
4
|
+
description: 'What the onboarding collects: a first name, so the app can address the user.',
|
|
5
|
+
fields: (t) => ({
|
|
6
|
+
firstName: t.field({
|
|
7
|
+
type: 'PersonName',
|
|
8
|
+
required: true,
|
|
9
|
+
description: "The user's first name.",
|
|
10
|
+
}),
|
|
11
|
+
}),
|
|
12
|
+
})
|