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.
This commit is contained in:
+14
-4
@@ -51,6 +51,7 @@ export function mockUserWithProfile(overrides?: Record<string, unknown>) {
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '555-0100'
|
||||
},
|
||||
profiles: [{ id: 'profile-1', userId: 'user-1' }],
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
@@ -76,6 +77,7 @@ export function mockUserWithProfileAndAddresses(overrides?: Record<string, unkno
|
||||
}
|
||||
]
|
||||
},
|
||||
profiles: [{ id: 'profile-1', userId: 'user-1' }],
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
@@ -100,6 +102,7 @@ export function mockProclamation(overrides?: Record<string, unknown>) {
|
||||
content: '<p>Test content</p>',
|
||||
solfeggioFrequencyId: 1,
|
||||
categoryId: 1,
|
||||
groupId: 1,
|
||||
price: 0.99,
|
||||
solfeggioFrequency: {
|
||||
id: 1,
|
||||
@@ -108,9 +111,7 @@ export function mockProclamation(overrides?: Record<string, unknown>) {
|
||||
},
|
||||
category: {
|
||||
id: 1,
|
||||
name: 'Healing',
|
||||
parentId: null,
|
||||
parent: null
|
||||
name: 'Healing'
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
@@ -121,7 +122,16 @@ export function mockCategory(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Healing',
|
||||
parentId: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal proclamation group. */
|
||||
export function mockGroup(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'General',
|
||||
sortOrder: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const profileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
|
||||
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
|
||||
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
|
||||
const profileUpdateMany = prisma.profile.updateMany as ReturnType<typeof vi.fn>;
|
||||
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
|
||||
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
|
||||
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
|
||||
const proclamationCount = prisma.proclamation.count as ReturnType<typeof vi.fn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<unknown>[]) => 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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user