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:
NeilClack
2026-08-27 12:04:09 -05:00
parent b76a540dc4
commit 4373458272
9 changed files with 126 additions and 78 deletions
@@ -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);
});
});
+2 -2
View File
@@ -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');
}
+2 -2
View File
@@ -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');