Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions layers/identity/app/components/LikeButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -154,17 +151,21 @@ const revealClass = computed(() => {

<p
v-if="showNudge"
class="flex items-start gap-2 text-xs text-muted"
class="flex items-start gap-2 text-sm text-muted"
>
<span class="flex-1">
Liked. Its changes land in your next weekly.
Liked. Email updates are optional.
<NuxtLink to="/onboarding/email" class="inline-flex min-h-11 items-center underline hover:text-default">
Email updates
</NuxtLink>
</span>
<UButton
icon="i-lucide-x"
size="xs"
color="neutral"
variant="ghost"
aria-label="Dismiss"
class="min-h-11 min-w-11"
@click="dismissNudge"
/>
</p>
Expand Down
15 changes: 12 additions & 3 deletions layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {

const actionFailed = useActionFailure()
const rpc = useNuxtRpc()
const { fetchSession } = useAuth()

async function refreshAccount() {
me.value = await rpc.query(identityAccountQueries.me())
Expand All @@ -58,6 +59,7 @@ const removeSubscriptionMutation = useNuxtMutation<IdentitySubscriptionRef, Iden
const saveEmailMutation = useNuxtMutation<IdentityEmailPatchBody, IdentityMutationResponse>({
mutation: async (body) => {
const result = await rpc.execute(identityAccountQueries.saveEmail(), body)
await fetchSession()
await refreshAccount()
return result
},
Expand Down Expand Up @@ -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',
})
Expand Down Expand Up @@ -273,7 +275,7 @@ async function deleteAccount() {
Your skills
</h1>
<p class="mt-3 max-w-2xl text-base leading-relaxed text-muted text-pretty">
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.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-4">
Expand All @@ -284,6 +286,13 @@ async function deleteAccount() {
</div>
</div>

<div v-if="me.onboarded_at === null" class="mt-6 flex flex-col items-start gap-3 rounded-lg border border-default p-4 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-relaxed text-muted">
Choose which emails you receive. You can change this later.
</p>
<UButton to="/onboarding/email" label="Choose email updates" color="neutral" variant="outline" class="min-h-11 shrink-0" />
</div>

<div v-if="likesLoading" class="editorial-state mt-6 flex flex-col items-start justify-center" role="status">
<UIcon name="i-lucide-loader-circle" class="size-5 animate-spin text-muted" aria-hidden="true" />
<h2 class="mt-4 text-lg font-semibold">
Expand Down Expand Up @@ -346,7 +355,7 @@ async function deleteAccount() {
Add your first skill
</h2>
<p class="mt-2 max-w-xl text-sm leading-relaxed text-muted">
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.
</p>
<UButton to="/skills" class="mt-5 min-h-11" icon="i-lucide-search" label="Browse skills" />
</div>
Expand Down
15 changes: 10 additions & 5 deletions layers/identity/app/pages/onboarding/email.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -95,14 +100,14 @@ useSeoMeta({ title: 'Email opt-in', robots: 'noindex' })
</div>

<label class="flex min-h-11 items-start gap-3 cursor-pointer">
<input v-model="weeklyOptIn" type="checkbox" class="mt-0.5 size-4 accent-primary">
<input v-model="weeklyOptIn" type="checkbox" :disabled="submitting" class="mt-0.5 size-4 accent-primary">
<span class="text-sm text-muted leading-relaxed">
Send me distinct trending Skills each Monday.
</span>
</label>

<label class="flex min-h-11 items-start gap-3 cursor-pointer">
<input v-model="optIn" type="checkbox" class="mt-0.5 size-4 accent-primary">
<input v-model="optIn" type="checkbox" :disabled="submitting" class="mt-0.5 size-4 accent-primary">
<span class="text-sm text-muted leading-relaxed">
Send me watched changes once a month.
</span>
Expand Down
10 changes: 9 additions & 1 deletion layers/identity/server/api/me/email.patch.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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 }
},
})
5 changes: 5 additions & 0 deletions layers/identity/server/utils/account-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions test/unit/account-email-completion.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
17 changes: 17 additions & 0 deletions test/unit/digest-opt-in-address.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => 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(`
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 23 additions & 1 deletion test/unit/email-form-submit.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -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 }) => {
Expand Down
18 changes: 17 additions & 1 deletion test/unit/like-button.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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 }),
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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()
Expand Down
Loading