diff --git a/layers/identity/app/components/LikeButton.vue b/layers/identity/app/components/LikeButton.vue
index b4161d1a..adb01ded 100644
--- a/layers/identity/app/components/LikeButton.vue
+++ b/layers/identity/app/components/LikeButton.vue
@@ -36,11 +36,8 @@ const nudgeDismissed = ref(true)
/**
* Shown once, to a signed-in user who has not finished onboarding.
*
- * It used to invite them to "set up digest", which asked for configuration the
- * weekly does not need: the send already falls back to the GitHub address on
- * the account. So it now confirms what the like just did instead of asking for
- * anything, and only offers the email step when there is genuinely no address
- * to send to.
+ * A like saves the Skill. It does not grant email consent. The weekly carries
+ * trending Skills; changes to liked Skills belong in the monthly digest.
*/
const showNudge = computed(() =>
variant === 'detail'
@@ -154,10 +151,13 @@ const revealClass = computed(() => {
- Liked. Its changes land in your next weekly.
+ Liked. Email updates are optional.
+
+ Email updates
+
{
color="neutral"
variant="ghost"
aria-label="Dismiss"
+ class="min-h-11 min-w-11"
@click="dismissNudge"
/>
diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue
index be5f3010..062f9efc 100644
--- a/layers/identity/app/pages/me/index.vue
+++ b/layers/identity/app/pages/me/index.vue
@@ -36,6 +36,7 @@ const {
const actionFailed = useActionFailure()
const rpc = useNuxtRpc()
+const { fetchSession } = useAuth()
async function refreshAccount() {
me.value = await rpc.query(identityAccountQueries.me())
@@ -58,6 +59,7 @@ const removeSubscriptionMutation = useNuxtMutation({
mutation: async (body) => {
const result = await rpc.execute(identityAccountQueries.saveEmail(), body)
+ await fetchSession()
await refreshAccount()
return result
},
@@ -179,7 +181,7 @@ onMounted(() => {
if (route.query.welcome === '1') {
toast.add({
title: 'You\'re all set',
- description: 'Watched repos send a digest when they change.',
+ description: 'Your email choices are saved. You can change them in Email updates.',
color: 'success',
icon: 'i-lucide-check-circle',
})
@@ -273,7 +275,7 @@ async function deleteAccount() {
Your skills
- Like a skill to keep it here. We track meaningful changes and include them in your digest.
+ Like a skill to keep it here. Email updates are optional.
@@ -284,6 +286,13 @@ async function deleteAccount() {
+
+
+ Choose which emails you receive. You can change this later.
+
+
+
+
@@ -346,7 +355,7 @@ async function deleteAccount() {
Add your first skill
- Like any skill to keep it here and receive useful changes in your digest.
+ Like any skill to keep it here. Turn on the monthly digest to receive its changes.
diff --git a/layers/identity/app/pages/onboarding/email.vue b/layers/identity/app/pages/onboarding/email.vue
index 7e4782d9..a7715b62 100644
--- a/layers/identity/app/pages/onboarding/email.vue
+++ b/layers/identity/app/pages/onboarding/email.vue
@@ -8,12 +8,17 @@ const { data: me, error: accountError, status: accountStatus, refresh: retryAcco
const { fetchSession } = useAuth()
const email = ref(me.value?.digest_email || me.value?.email || '')
+// A stored address records a deliberate choice, including a previous opt-out.
+const hasEmailChoices = computed(() => !!me.value?.onboarded_at || !!me.value?.digest_email)
+const optIn = ref(hasEmailChoices.value ? !!me.value?.email_opt_in : true)
+const weeklyOptIn = ref(hasEmailChoices.value ? !!me.value?.weekly_opt_in : true)
watch(me, (account, previous) => {
- if (account && !previous)
+ if (account && !previous) {
email.value = account.digest_email || account.email || ''
+ optIn.value = hasEmailChoices.value ? account.email_opt_in : true
+ weeklyOptIn.value = hasEmailChoices.value ? account.weekly_opt_in : true
+ }
})
-const optIn = ref(true)
-const weeklyOptIn = ref(true)
const actionFailed = useActionFailure()
const rpc = useNuxtRpc()
@@ -95,14 +100,14 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
-
+
Send me distinct trending Skills each Monday.
-
+
Send me watched changes once a month.
diff --git a/layers/identity/server/api/me/email.patch.ts b/layers/identity/server/api/me/email.patch.ts
index 487d3d77..72827d84 100644
--- a/layers/identity/server/api/me/email.patch.ts
+++ b/layers/identity/server/api/me/email.patch.ts
@@ -1,3 +1,4 @@
+import type { UserSession } from '#auth-utils'
import { defineApiHandler } from '#shared/server/handler'
import { identityEmailPatchBodySchema, identityMutationResponseSchema } from '../../../shared/contracts/account'
import { authenticated } from '../../policies/authenticated'
@@ -8,7 +9,7 @@ export default defineApiHandler({
schema: identityEmailPatchBodySchema,
policy: [authenticated],
response: identityMutationResponseSchema,
- handler: async ({ event, body, platform }) => {
+ handler: async ({ event, body, platform, session }) => {
const u = await requireUserRow(event)
// The schema drops blank addresses, so an opted-in caller can never wipe
// the stored one here. Absent means unchanged, for every field: an
@@ -21,6 +22,13 @@ export default defineApiHandler({
if (plan._tag === 'MissingAddress')
throw createError({ statusCode: 400, message: 'Add a valid email address to receive emails' })
await updateAccountSettings(platform.db, u.id, plan.columns)
+ const current = session as unknown as UserSession | null
+ if (current?.user && (body.email_opt_in !== undefined || body.weekly_opt_in !== undefined)) {
+ await setUserSession(event, {
+ ...current,
+ user: { ...current.user, onboarded: true },
+ })
+ }
return { ok: true as const }
},
})
diff --git a/layers/identity/server/utils/account-settings.ts b/layers/identity/server/utils/account-settings.ts
index 46c7e352..c913e4e0 100644
--- a/layers/identity/server/utils/account-settings.ts
+++ b/layers/identity/server/utils/account-settings.ts
@@ -70,6 +70,11 @@ export async function updateAccountSettings(
if (!named.length)
return
const assignments = named.map((column, index) => `${column} = ?${index + 1}`)
+ // Email choices finish setup from every entry point, including /me and the
+ // CLI. A separate onboarding request must not gate an explicit digest opt-in.
+ // Keep the first completion date, which starts the digest activity window.
+ if (columns.email_opt_in !== undefined || columns.weekly_opt_out !== undefined)
+ assignments.push('onboarded_at = COALESCE(onboarded_at, unixepoch())')
await db.prepare(`UPDATE users SET ${assignments.join(', ')} WHERE id = ?${named.length + 1}`)
.bind(...named.map(column => columns[column]!), userId)
.run()
diff --git a/test/unit/account-email-completion.test.ts b/test/unit/account-email-completion.test.ts
new file mode 100644
index 00000000..d6683d7b
--- /dev/null
+++ b/test/unit/account-email-completion.test.ts
@@ -0,0 +1,41 @@
+import type { SqliteD1 } from './helpers/d1-sqlite'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { planAccountSettings, updateAccountSettings } from '../../layers/identity/server/utils/account-settings'
+import { loadDigestEligibleUsers } from '../../layers/identity/server/utils/digest-select'
+import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
+
+describe('email choices complete account setup', () => {
+ let d1: SqliteD1
+ beforeEach(() => {
+ d1 = createSqliteD1(allMigrations())
+ d1.raw.exec(`INSERT INTO users (id, github_id, login, email, created_at, last_login_at)
+ VALUES (900001, 900001, 'new-account', 'account@example.com', 1, 1)`)
+ })
+ afterEach(() => d1.close())
+
+ it('makes a digest opt-in eligible without a separate onboarding request', async () => {
+ const plan = planAccountSettings({ email: 'account@example.com', digest_email: null }, { digest: true, weekly: false })
+ if (plan._tag !== 'Ok')
+ throw new Error('Expected valid email choices')
+ await updateAccountSettings(d1.db, 900001, plan.columns)
+ expect((await loadDigestEligibleUsers(d1.db)).map(user => user.login)).toEqual(['new-account'])
+ })
+
+ it('records an explicit opt-out without enabling either email', async () => {
+ await updateAccountSettings(d1.db, 900001, { email_opt_in: 0, weekly_opt_out: 1 })
+ const row = d1.raw.prepare('SELECT onboarded_at, email_opt_in, weekly_opt_out FROM users WHERE id = 900001').get()
+ expect(row).toMatchObject({ onboarded_at: expect.any(Number), email_opt_in: 0, weekly_opt_out: 1 })
+ expect(await loadDigestEligibleUsers(d1.db)).toEqual([])
+ })
+
+ it('keeps the original completion date on later email changes', async () => {
+ d1.raw.exec('UPDATE users SET onboarded_at = 123 WHERE id = 900001')
+ await updateAccountSettings(d1.db, 900001, { email_opt_in: 1 })
+ expect(d1.raw.prepare('SELECT onboarded_at FROM users WHERE id = 900001').get()).toEqual({ onboarded_at: 123 })
+ })
+
+ it('leaves setup incomplete for privacy or address-only changes', async () => {
+ await updateAccountSettings(d1.db, 900001, { likes_public: 0, digest_email: 'changed@example.com' })
+ expect(d1.raw.prepare('SELECT onboarded_at FROM users WHERE id = 900001').get()).toEqual({ onboarded_at: null })
+ })
+})
diff --git a/test/unit/digest-opt-in-address.test.ts b/test/unit/digest-opt-in-address.test.ts
index daf59523..45af6fe4 100644
--- a/test/unit/digest-opt-in-address.test.ts
+++ b/test/unit/digest-opt-in-address.test.ts
@@ -102,12 +102,15 @@ describe('digest opt-in requires a deliverable address', () => {
describe('me email patch endpoint', () => {
let sqlite: Database.Database
let emailPatchHandler: EventHandler
+ const establishSession = vi.fn()
beforeEach(async () => {
vi.resetModules()
+ establishSession.mockReset()
vi.stubGlobal('defineEventHandler', (handler: EventHandler) => handler)
vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input))
vi.stubGlobal('getUserSession', () => Promise.resolve({ user: { id: 1, login: 'harlan' } }))
+ vi.stubGlobal('setUserSession', establishSession)
sqlite = new Database(':memory:')
sqlite.exec(`
@@ -161,6 +164,20 @@ describe('me email patch endpoint', () => {
.toEqual({ digest_email: 'new@example.com', email_opt_in: 1, weekly_opt_out: 0 })
})
+ it('refreshes browser completion after an explicit email choice', async () => {
+ vi.stubGlobal('readBody', () => Promise.resolve({ email_opt_in: false, weekly_opt_in: false }))
+ await emailPatchHandler(patchEvent())
+ expect(establishSession).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ user: { id: 1, login: 'harlan', onboarded: true },
+ }))
+ })
+
+ it('does not complete browser setup for an address-only patch', async () => {
+ vi.stubGlobal('readBody', () => Promise.resolve({ digest_email: 'changed@example.com' }))
+ await emailPatchHandler(patchEvent())
+ expect(establishSession).not.toHaveBeenCalled()
+ })
+
function patchEvent(): H3Event {
return {
method: 'PATCH',
diff --git a/test/unit/email-form-submit.nuxt.test.ts b/test/unit/email-form-submit.nuxt.test.ts
index 694214e4..35b04589 100644
--- a/test/unit/email-form-submit.nuxt.test.ts
+++ b/test/unit/email-form-submit.nuxt.test.ts
@@ -17,7 +17,7 @@ const account = ref({
repo_indexing: false,
timezone: 'UTC',
stars_synced_at: null,
- onboarded_at: null,
+ onboarded_at: null as number | null,
})
mockNuxtImport('useNuxtRpcQuery', () => (operation: { path: string }) => ({
@@ -45,6 +45,28 @@ beforeEach(() => {
vi.clearAllMocks()
mocks.execute.mockResolvedValue({ ok: true })
mocks.query.mockResolvedValue({ ...account.value })
+ account.value.onboarded_at = null
+ account.value.email_opt_in = true
+ account.value.weekly_opt_in = true
+})
+
+it('keeps saved email choices when returning to completed onboarding', async () => {
+ account.value.onboarded_at = 1
+ account.value.email_opt_in = false
+ account.value.weekly_opt_in = false
+ const wrapper = await mountSuspended(OnboardingEmail, { route: '/onboarding/email' })
+ for (const checkbox of wrapper.findAll('input[type="checkbox"]'))
+ expect((checkbox.element as HTMLInputElement).checked).toBe(false)
+ wrapper.unmount()
+})
+
+it('offers unfinished accounts a path back to email choices', async () => {
+ const wrapper = await mountSuspended(Account, { route: '/me' })
+ expect(wrapper.get('a[href="/onboarding/email"]').text()).toBe('Choose email updates')
+ account.value.onboarded_at = 1
+ await flushPromises()
+ expect(wrapper.find('a[href="/onboarding/email"]').exists()).toBe(false)
+ wrapper.unmount()
})
describe.each(screens)('$name email submission', ({ component, form: selector }) => {
diff --git a/test/unit/like-button.nuxt.test.ts b/test/unit/like-button.nuxt.test.ts
index 2d98a5d6..8c39d090 100644
--- a/test/unit/like-button.nuxt.test.ts
+++ b/test/unit/like-button.nuxt.test.ts
@@ -6,6 +6,7 @@ import LikeButton from '../../layers/identity/app/components/LikeButton.vue'
import { useLikes } from '../../layers/identity/app/composables/useLikes'
const loggedIn = ref(true)
+const onboarded = ref(true)
/** False while the browser is still loading the session after hydration. */
const sessionKnown = ref(true)
const fetchMock = vi.hoisted(() => vi.fn())
@@ -24,7 +25,7 @@ mockNuxtImport('useAuth', () => () => ({
if (!sessionKnown.value)
return { _tag: 'pending' }
return loggedIn.value
- ? { _tag: 'signed-in', user: { login: 'harlan', onboarded: true } }
+ ? { _tag: 'signed-in', user: { login: 'harlan', onboarded: onboarded.value } }
: { _tag: 'anonymous' }
}),
user: ref({ login: 'harlan', onboarded: true }),
@@ -75,6 +76,8 @@ beforeEach(() => {
mutation = { _tag: 'ok', likeCount: 0 }
listGate = null
loggedIn.value = true
+ onboarded.value = true
+ sessionStorage.clear()
sessionKnown.value = true
fetchMock.mockReset()
fetchMock.mockImplementation(async (path: string, options?: { method?: string }) => {
@@ -92,6 +95,19 @@ beforeEach(() => {
})
describe('likeButton toggle semantics', () => {
+ it('offers email choices after a first like without promising an unconsented email', async () => {
+ onboarded.value = false
+ likedItems = [{ ...skill, likeCount: 1 }]
+ const wrapper = await mountSuspended(LikeButton, { props: skill })
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('land in your next weekly')
+ expect(wrapper.get('a[href="/onboarding/email"]').text()).toBe('Email updates')
+ await wrapper.get('button[aria-label="Dismiss"]').trigger('click')
+ expect(wrapper.find('a[href="/onboarding/email"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
it('exposes pressed state and an action-specific label that follows the like', async () => {
const wrapper = await mountSuspended(LikeButton, { props: { ...skill, count: 4 } })
await flushPromises()