From 4373458272fcad3a469bfd5b4e86010f7976bb65 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 27 Aug 2026 12:04:09 -0500 Subject: [PATCH] Allow profiles and license keys without a user; link them on registration Admins can now create a Profile or License Key for someone who hasn't registered yet (matched by email). When that person registers, any unattached profile and license keys are linked to their new account. Also remove the Proclamations surface from the user dashboard. --- src/lib/test-utils.ts | 18 +++++-- .../(user)/account/+page.server.ts | 20 ++++---- .../(user)/account/page.server.spec.ts | 38 +++++++------- .../(user)/dashboard/+page.server.ts | 18 +++---- .../(user)/dashboard/page.server.spec.ts | 33 +++---------- src/routes/(protected)/+layout.server.ts | 4 +- src/routes/(protected)/layout.server.spec.ts | 4 +- src/routes/register/+page.server.ts | 20 +++++++- src/routes/register/page.server.spec.ts | 49 +++++++++++++++++-- 9 files changed, 126 insertions(+), 78 deletions(-) diff --git a/src/lib/test-utils.ts b/src/lib/test-utils.ts index 2a97ad3..a3113e0 100644 --- a/src/lib/test-utils.ts +++ b/src/lib/test-utils.ts @@ -51,6 +51,7 @@ export function mockUserWithProfile(overrides?: Record) { lastName: 'Doe', phoneNumber: '555-0100' }, + profiles: [{ id: 'profile-1', userId: 'user-1' }], ...overrides }); } @@ -76,6 +77,7 @@ export function mockUserWithProfileAndAddresses(overrides?: Record) { content: '

Test content

', solfeggioFrequencyId: 1, categoryId: 1, + groupId: 1, price: 0.99, solfeggioFrequency: { id: 1, @@ -108,9 +111,7 @@ export function mockProclamation(overrides?: Record) { }, category: { id: 1, - name: 'Healing', - parentId: null, - parent: null + name: 'Healing' }, ...overrides }; @@ -121,7 +122,16 @@ export function mockCategory(overrides?: Record) { return { id: 1, name: 'Healing', - parentId: null, + ...overrides + }; +} + +/** Minimal proclamation group. */ +export function mockGroup(overrides?: Record) { + return { + id: 1, + name: 'General', + sortOrder: 0, ...overrides }; } diff --git a/src/routes/(protected)/(user)/account/+page.server.ts b/src/routes/(protected)/(user)/account/+page.server.ts index f122bb4..e6afe7d 100644 --- a/src/routes/(protected)/(user)/account/+page.server.ts +++ b/src/routes/(protected)/(user)/account/+page.server.ts @@ -8,20 +8,18 @@ export const load: PageServerLoad = async ({ locals }) => { throw error(403, 'not authorized'); } - const user = await prisma.user.findUnique({ - where: { id: locals.user.id }, - include: { - profile: { - include: { addresses: true } - } - } - }); + const user = await prisma.user.findUnique({ where: { id: locals.user.id } }); if (!user) { throw error(403, 'not authorized'); } - return { user }; + const profile = await prisma.profile.findFirst({ + where: { userId: user.id }, + include: { addresses: true } + }); + + return { user: { ...user, profile } }; }; export const actions: Actions = { @@ -40,7 +38,7 @@ export const actions: Actions = { } try { - await prisma.profile.update({ + await prisma.profile.updateMany({ where: { userId: locals.user.id }, data: { firstName, lastName, phoneNumber } }); @@ -67,7 +65,7 @@ export const actions: Actions = { return fail(400, { error: 'All address fields are required.', action: 'addAddress' }); } - const profile = await prisma.profile.findUnique({ + const profile = await prisma.profile.findFirst({ where: { userId: locals.user.id }, select: { id: true } }); diff --git a/src/routes/(protected)/(user)/account/page.server.spec.ts b/src/routes/(protected)/(user)/account/page.server.spec.ts index 3fd4b5e..c09dc29 100644 --- a/src/routes/(protected)/(user)/account/page.server.spec.ts +++ b/src/routes/(protected)/(user)/account/page.server.spec.ts @@ -10,7 +10,7 @@ import { vi.mock('$lib/db', () => ({ prisma: { user: { findUnique: vi.fn() }, - profile: { update: vi.fn(), findUnique: vi.fn() }, + profile: { findFirst: vi.fn(), updateMany: vi.fn() }, address: { create: vi.fn() } } })); @@ -19,8 +19,8 @@ import { prisma } from '$lib/db'; import { load, actions } from './+page.server'; const userFindUnique = prisma.user.findUnique as ReturnType; -const profileUpdate = prisma.profile.update as ReturnType; -const profileFindUnique = prisma.profile.findUnique as ReturnType; +const profileFindFirst = prisma.profile.findFirst as ReturnType; +const profileUpdateMany = prisma.profile.updateMany as ReturnType; const addressCreate = prisma.address.create as ReturnType; const invokeAction = (name: string, request: Request, locals: App.Locals) => @@ -29,8 +29,8 @@ const invokeAction = (name: string, request: Request, locals: App.Locals) => describe('(protected)/account page server', () => { beforeEach(() => { userFindUnique.mockReset(); - profileUpdate.mockReset(); - profileFindUnique.mockReset(); + profileFindFirst.mockReset(); + profileUpdateMany.mockReset(); addressCreate.mockReset(); }); @@ -51,24 +51,26 @@ describe('(protected)/account page server', () => { }); it('returns user with profile and addresses', async () => { - const user = mockUserWithProfileAndAddresses(); + const { profile, ...user } = mockUserWithProfileAndAddresses() as any; userFindUnique.mockResolvedValue(user); + profileFindFirst.mockResolvedValue(profile); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; - expect(result.user).toEqual(user); + expect(result.user).toEqual({ ...user, profile }); expect(result.user.profile.addresses).toHaveLength(1); }); - it('includes addresses in the query', async () => { - userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses()); + it('loads the profile with addresses by user id', async () => { + userFindUnique.mockResolvedValue(mockUser()); + profileFindFirst.mockResolvedValue({ id: 'profile-1' }); const locals = createLocals(mockUser() as any); await load({ locals } as any); - expect(userFindUnique).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - include: { profile: { include: { addresses: true } } } + expect(profileFindFirst).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + include: { addresses: true } }); }); }); @@ -125,7 +127,7 @@ describe('(protected)/account page server', () => { }); it('updates profile with server-formatted phone number on success', async () => { - profileUpdate.mockResolvedValue({}); + profileUpdateMany.mockResolvedValue({}); const request = createFormRequest({ firstName: 'Jane', @@ -136,14 +138,14 @@ describe('(protected)/account page server', () => { const locals = createLocals(mockUser() as any); const result = await invokeAction('updateProfile', request, locals); expect(result).toEqual({ success: true, action: 'updateProfile' }); - expect(profileUpdate).toHaveBeenCalledWith({ + expect(profileUpdateMany).toHaveBeenCalledWith({ where: { userId: 'user-1' }, data: { firstName: 'Jane', lastName: 'Smith', phoneNumber: '8175551234' } }); }); it('returns fail(500) when prisma update throws', async () => { - profileUpdate.mockRejectedValue(new Error('db error')); + profileUpdateMany.mockRejectedValue(new Error('db error')); const request = createFormRequest({ firstName: 'Jane', @@ -190,7 +192,7 @@ describe('(protected)/account page server', () => { }); it('throws 400 when profile is not found', async () => { - profileFindUnique.mockResolvedValue(null); + profileFindFirst.mockResolvedValue(null); const request = createFormRequest({ street: '123 Main St', @@ -208,7 +210,7 @@ describe('(protected)/account page server', () => { }); it('creates address linked to profile on success', async () => { - profileFindUnique.mockResolvedValue({ id: 'profile-1' }); + profileFindFirst.mockResolvedValue({ id: 'profile-1' }); addressCreate.mockResolvedValue({}); const request = createFormRequest({ @@ -235,7 +237,7 @@ describe('(protected)/account page server', () => { }); it('returns fail(500) when prisma create throws', async () => { - profileFindUnique.mockResolvedValue({ id: 'profile-1' }); + profileFindFirst.mockResolvedValue({ id: 'profile-1' }); addressCreate.mockRejectedValue(new Error('db error')); const request = createFormRequest({ diff --git a/src/routes/(protected)/(user)/dashboard/+page.server.ts b/src/routes/(protected)/(user)/dashboard/+page.server.ts index 83acd01..8e187e0 100644 --- a/src/routes/(protected)/(user)/dashboard/+page.server.ts +++ b/src/routes/(protected)/(user)/dashboard/+page.server.ts @@ -7,19 +7,16 @@ export const load: PageServerLoad = async ({ locals }) => { throw error(403, 'not authorized'); } - const profile = await prisma.profile.findUnique({ + const profile = await prisma.profile.findFirst({ where: { userId: locals.user.id }, select: { firstName: true } }); - const [keyStats, proclamationCount] = await Promise.all([ - prisma.licenseKey.groupBy({ - by: ['active'], - where: { userId: locals.user.id }, - _count: true - }), - prisma.proclamation.count() - ]); + const keyStats = await prisma.licenseKey.groupBy({ + by: ['active'], + where: { userId: locals.user.id }, + _count: true + }); const activeKeys = keyStats.find((s) => s.active)?._count ?? 0; const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0); @@ -27,7 +24,6 @@ export const load: PageServerLoad = async ({ locals }) => { return { firstName: profile?.firstName ?? 'User', activeKeys, - totalKeys, - proclamationCount + totalKeys }; }; diff --git a/src/routes/(protected)/(user)/dashboard/page.server.spec.ts b/src/routes/(protected)/(user)/dashboard/page.server.spec.ts index 7a9c588..fe27011 100644 --- a/src/routes/(protected)/(user)/dashboard/page.server.spec.ts +++ b/src/routes/(protected)/(user)/dashboard/page.server.spec.ts @@ -4,24 +4,21 @@ import { createLocals, mockUser } from '$lib/test-utils'; vi.mock('$lib/db', () => ({ prisma: { - profile: { findUnique: vi.fn() }, - licenseKey: { groupBy: vi.fn() }, - proclamation: { count: vi.fn() } + profile: { findFirst: vi.fn() }, + licenseKey: { groupBy: vi.fn() } } })); import { prisma } from '$lib/db'; import { load } from './+page.server'; -const profileFindUnique = prisma.profile.findUnique as ReturnType; +const profileFindFirst = prisma.profile.findFirst as ReturnType; const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType; -const proclamationCount = prisma.proclamation.count as ReturnType; describe('(protected)/dashboard page server load', () => { beforeEach(() => { - profileFindUnique.mockReset(); + profileFindFirst.mockReset(); licenseKeyGroupBy.mockReset(); - proclamationCount.mockReset(); }); it('throws 403 when locals.user is undefined', async () => { @@ -32,9 +29,8 @@ describe('(protected)/dashboard page server load', () => { }); it('returns firstName from profile', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); + profileFindFirst.mockResolvedValue({ firstName: 'Jane' }); licenseKeyGroupBy.mockResolvedValue([]); - proclamationCount.mockResolvedValue(0); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; @@ -42,9 +38,8 @@ describe('(protected)/dashboard page server load', () => { }); it('defaults firstName to "User" when profile is null', async () => { - profileFindUnique.mockResolvedValue(null); + profileFindFirst.mockResolvedValue(null); licenseKeyGroupBy.mockResolvedValue([]); - proclamationCount.mockResolvedValue(0); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; @@ -52,12 +47,11 @@ describe('(protected)/dashboard page server load', () => { }); it('computes activeKeys and totalKeys from groupBy results', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); + profileFindFirst.mockResolvedValue({ firstName: 'Jane' }); licenseKeyGroupBy.mockResolvedValue([ { active: true, _count: 3 }, { active: false, _count: 2 } ]); - proclamationCount.mockResolvedValue(10); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; @@ -66,23 +60,12 @@ describe('(protected)/dashboard page server load', () => { }); it('returns 0 for activeKeys when none are active', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); + profileFindFirst.mockResolvedValue({ firstName: 'Jane' }); licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]); - proclamationCount.mockResolvedValue(0); const locals = createLocals(mockUser() as any); const result = (await load({ locals } as any)) as any; expect(result.activeKeys).toBe(0); expect(result.totalKeys).toBe(4); }); - - it('returns proclamationCount', async () => { - profileFindUnique.mockResolvedValue({ firstName: 'Jane' }); - licenseKeyGroupBy.mockResolvedValue([]); - proclamationCount.mockResolvedValue(42); - - const locals = createLocals(mockUser() as any); - const result = (await load({ locals } as any)) as any; - expect(result.proclamationCount).toBe(42); - }); }); diff --git a/src/routes/(protected)/+layout.server.ts b/src/routes/(protected)/+layout.server.ts index 07bb9ad..8f3c813 100644 --- a/src/routes/(protected)/+layout.server.ts +++ b/src/routes/(protected)/+layout.server.ts @@ -9,12 +9,12 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { const user = await prisma.user.findUnique({ where: { id: locals.user.id }, - select: { id: true, roleId: true, profile: { select: { id: true } } } + select: { id: true, roleId: true, profiles: { select: { id: true } } } }); if (!user) { throw error(403, 'not authorized'); } - if (!user.profile && !url.pathname.startsWith('/account/setup')) { + if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) { // Skip redirect when already on /account/setup to avoid redirect loop throw redirect(303, '/account/setup'); } diff --git a/src/routes/(protected)/layout.server.spec.ts b/src/routes/(protected)/layout.server.spec.ts index 91f392b..d664dc0 100644 --- a/src/routes/(protected)/layout.server.spec.ts +++ b/src/routes/(protected)/layout.server.spec.ts @@ -40,7 +40,7 @@ describe('(protected) layout server load', () => { }); it('redirects to /account/setup when user has no profile', async () => { - findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null }); + findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] }); const locals = createLocals(mockUser() as any); const url = new URL('http://localhost/dashboard'); @@ -53,7 +53,7 @@ describe('(protected) layout server load', () => { }); it('skips redirect when already on /account/setup', async () => { - findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null }); + findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] }); const locals = createLocals(mockUser() as any); const url = new URL('http://localhost/account/setup'); diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 3b9e8b5..f7fad27 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -1,5 +1,6 @@ import type { Actions } from './$types'; import { auth } from '$lib/auth'; +import { prisma } from '$lib/db'; import { fail } from '@sveltejs/kit'; export const actions: Actions = { @@ -8,7 +9,8 @@ export const actions: Actions = { const firstName = data.get('firstName') as string; const lastName = data.get('lastName') as string; - const email = data.get('email') as string; + const rawEmail = data.get('email') as string; + const email = rawEmail?.trim().toLowerCase() ?? ''; const password = data.get('password') as string; const password2 = data.get('password2') as string; @@ -25,6 +27,22 @@ export const actions: Actions = { } }); + // Link any profile / license key that was created for this person + // before they had an account (matched by email). + const user = await prisma.user.findUnique({ where: { email } }); + if (user) { + await prisma.$transaction([ + prisma.profile.updateMany({ + where: { email, userId: null }, + data: { userId: user.id } + }), + prisma.licenseKey.updateMany({ + where: { email, userId: null }, + data: { userId: user.id } + }) + ]); + } + return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : 'An unexpected error occurred'; diff --git a/src/routes/register/page.server.spec.ts b/src/routes/register/page.server.spec.ts index f20f03e..0eab753 100644 --- a/src/routes/register/page.server.spec.ts +++ b/src/routes/register/page.server.spec.ts @@ -2,12 +2,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { isActionFailure } from '@sveltejs/kit'; import { createFormRequest } from '$lib/test-utils'; -const { signUpEmail } = vi.hoisted(() => ({ - signUpEmail: vi.fn() -})); +const { signUpEmail, userFindUnique, profileUpdateMany, licenseKeyUpdateMany, $transaction } = + vi.hoisted(() => ({ + signUpEmail: vi.fn(), + userFindUnique: vi.fn(), + profileUpdateMany: vi.fn(), + licenseKeyUpdateMany: vi.fn(), + $transaction: vi.fn((ops: Promise[]) => Promise.all(ops)) + })); vi.mock('$lib/db', () => ({ - prisma: {} + prisma: { + user: { findUnique: userFindUnique }, + profile: { updateMany: profileUpdateMany }, + licenseKey: { updateMany: licenseKeyUpdateMany }, + $transaction + } })); vi.mock('$lib/auth', () => ({ @@ -25,6 +35,10 @@ const invokeDefault = (request: Request) => actions.default({ request } as any); describe('register page server action', () => { beforeEach(() => { signUpEmail.mockReset(); + userFindUnique.mockReset(); + profileUpdateMany.mockReset(); + licenseKeyUpdateMany.mockReset(); + $transaction.mockClear(); }); it('returns fail(400) when passwords do not match', async () => { @@ -46,6 +60,7 @@ describe('register page server action', () => { it('returns success on successful signup', async () => { signUpEmail.mockResolvedValue({ user: { id: 'user-1' } }); + userFindUnique.mockResolvedValue(null); const request = createFormRequest({ firstName: 'Jane', @@ -66,6 +81,32 @@ describe('register page server action', () => { }); }); + it('links existing profile and license keys by email after signup', async () => { + const user = { id: 'user-1', email: 'jane@example.com' }; + signUpEmail.mockResolvedValue({ user }); + userFindUnique.mockResolvedValue(user); + + const request = createFormRequest({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + password: 'secret123', + password2: 'secret123' + }); + + const result = await invokeDefault(request); + expect(result).toEqual({ success: true }); + expect(profileUpdateMany).toHaveBeenCalledWith({ + where: { email: 'jane@example.com', userId: null }, + data: { userId: 'user-1' } + }); + expect(licenseKeyUpdateMany).toHaveBeenCalledWith({ + where: { email: 'jane@example.com', userId: null }, + data: { userId: 'user-1' } + }); + expect($transaction).toHaveBeenCalledTimes(1); + }); + it('returns fail(400) with error message when signUpEmail throws', async () => { signUpEmail.mockRejectedValue(new Error('email already in use'));