Fix staff(+) unable to edit or delete addresses

This commit is contained in:
NeilClack
2026-09-11 12:43:41 -05:00
parent cf8fc7b719
commit fa614b8c37
3 changed files with 234 additions and 4 deletions
@@ -22,6 +22,7 @@ const ProfileSchema = z.object({
});
const AddressSchema = z.object({
id: z.string().optional(),
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
@@ -147,8 +148,14 @@ export const actions = {
};
}
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
form.data;
const {
id,
profileId,
isDefaultMailing,
isDefaultBilling,
isDefaultShipping,
...addressData
} = form.data;
if (!profileId) {
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
@@ -181,7 +188,36 @@ export const actions = {
isDefaultShipping
};
if (id) {
const existing = await prisma.address.findFirst({ where: { id, profileId } });
if (!existing) {
return { success: false, action: 'upsertAddress', addressError: 'Address not found' };
}
const updated = await prisma.address.update({ where: { id }, data: payload });
return { success: true, action: 'upsertAddress', address: updated };
}
const created = await prisma.address.create({ data: payload });
return { success: true, action: 'upsertAddress', address: created };
},
deleteAddress: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'deleteAddress', addressError: 'Not authorized' };
}
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) {
return { success: false, action: 'deleteAddress', addressError: 'Missing address id' };
}
try {
await prisma.address.delete({ where: { id } });
} catch (err) {
console.error('Address delete error:', err);
return { success: false, action: 'deleteAddress', addressError: 'Failed to delete address' };
}
return { success: true, action: 'deleteAddress', addressId: id };
}
};
@@ -11,6 +11,7 @@
fieldErrors?: Record<string, string>;
addressError?: string;
address?: Record<string, unknown>;
addressId?: string;
} | null;
let {
@@ -32,6 +33,7 @@
let addressError = $state('');
let successMessage = $state('');
let addressFormOpen = $state(false);
let editingAddressId = $state<string | null>(null);
let addresses = $state<Record<string, unknown>[]>([]);
let addrStreet = $state('');
@@ -49,6 +51,7 @@
let lastForm = $state.raw<ActionResult>(untrack(() => form ?? null));
function resetAddressFields() {
editingAddressId = null;
addrStreet = '';
addrCity = '';
addrState = '';
@@ -89,7 +92,11 @@
switch (form.action) {
case 'upsertAddress':
if (form.success && form.address) {
addresses = [...addresses, form.address];
const savedId = String(form.address.id ?? '');
const exists = addresses.some((a) => String(a.id ?? '') === savedId);
addresses = exists
? addresses.map((a) => (String(a.id ?? '') === savedId ? form.address! : a))
: [...addresses, form.address];
addressFormOpen = false;
addressError = '';
successMessage = 'Address saved';
@@ -99,6 +106,16 @@
addressError = form.addressError ?? '';
}
break;
case 'deleteAddress':
if (form.success) {
addresses = addresses.filter((a) => String(a.id ?? '') !== String(form.addressId ?? ''));
addressError = '';
successMessage = 'Address deleted';
} else {
successMessage = '';
addressError = form.addressError ?? '';
}
break;
case 'upsert':
default:
if (form.success) {
@@ -119,6 +136,21 @@
addressError = '';
if (addressFormOpen) resetAddressFields();
}
function openEditAddress(addr: Record<string, unknown>) {
resetAddressFields();
editingAddressId = String(addr.id ?? '');
addrStreet = String(addr.street ?? '');
addrCity = String(addr.city ?? '');
addrState = String(addr.state ?? '');
addrPostal = String(addr.postalCode ?? '');
addrCountry = String(addr.country ?? '');
addrMailing = Boolean(addr.isDefaultMailing);
addrBilling = Boolean(addr.isDefaultBilling);
addrShipping = Boolean(addr.isDefaultShipping);
addressError = '';
addressFormOpen = true;
}
</script>
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
@@ -172,6 +204,9 @@
{#if addressFormOpen}
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
{#if editingAddressId}
<input type="hidden" name="id" value={editingAddressId} />
{/if}
{#if addressError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{addressError}
@@ -262,7 +297,7 @@
type="submit"
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
>
Save Address
{editingAddressId ? 'Update Address' : 'Save Address'}
</button>
</div>
</form>
@@ -283,6 +318,7 @@
<th scope="col" class="px-3 py-2">M</th>
<th scope="col" class="px-3 py-2">B</th>
<th scope="col" class="px-3 py-2">S</th>
<th scope="col" class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
@@ -296,6 +332,26 @@
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultShipping ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">
<div class="flex gap-2">
<button
type="button"
onclick={() => openEditAddress(addr)}
class="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
>
Edit
</button>
<form method="POST" action="?/deleteAddress" use:enhance>
<input type="hidden" name="id" value={String(addr.id ?? '')} />
<button
type="submit"
class="rounded-md border border-red-300 bg-white px-2 py-1 text-xs font-medium text-red-600 transition hover:bg-red-50"
>
Delete
</button>
</form>
</div>
</td>
</tr>
{/each}
</tbody>
@@ -14,6 +14,12 @@ vi.mock('$lib/db', () => ({
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn()
},
address: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
@@ -29,6 +35,10 @@ const mockProfileDelete = prisma.profile.delete as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserFindFirst = prisma.user.findFirst as ReturnType<typeof vi.fn>;
const mockUserUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const mockAddressFindFirst = prisma.address.findFirst as ReturnType<typeof vi.fn>;
const mockAddressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const mockAddressUpdate = prisma.address.update as ReturnType<typeof vi.fn>;
const mockAddressDelete = prisma.address.delete as ReturnType<typeof vi.fn>;
const validFields = {
email: 'jane@example.com',
@@ -242,4 +252,132 @@ describe('profiles admin page server', () => {
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
describe('upsertAddress', () => {
const addressFields = {
profileId: 'p1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
};
it('rejects non-staff users', async () => {
const result = await actions.upsertAddress({
request: createFormRequest(addressFields),
locals: createLocals(mockUser({ roleId: 4 }) as any)
} as any);
expect(result).toEqual({
success: false,
action: 'upsertAddress',
addressError: 'Not authorized'
});
});
it('creates a new address when no id is supplied', async () => {
const created = { id: 'a1', ...addressFields };
mockAddressCreate.mockResolvedValue(created);
const result = await actions.upsertAddress({
request: createFormRequest(addressFields),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsertAddress', address: created });
expect(mockAddressCreate).toHaveBeenCalledWith({
data: {
profileId: 'p1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US',
type: 'Mailing',
isDefaultMailing: false,
isDefaultBilling: false,
isDefaultShipping: false
}
});
});
it('updates an existing address when an id is supplied', async () => {
mockAddressFindFirst.mockResolvedValue({ id: 'a1' });
const updated = { id: 'a1', ...addressFields, street: '456 Oak Ave' };
mockAddressUpdate.mockResolvedValue(updated);
const result = await actions.upsertAddress({
request: createFormRequest({ ...addressFields, id: 'a1', street: '456 Oak Ave' }),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsertAddress', address: updated });
expect(mockAddressFindFirst).toHaveBeenCalledWith({
where: { id: 'a1', profileId: 'p1' }
});
expect(mockAddressUpdate).toHaveBeenCalledWith({
where: { id: 'a1' },
data: expect.objectContaining({ street: '456 Oak Ave', profileId: 'p1' })
});
expect(mockAddressCreate).not.toHaveBeenCalled();
});
it('errors when updating an address that does not belong to the profile', async () => {
mockAddressFindFirst.mockResolvedValue(null);
const result = await actions.upsertAddress({
request: createFormRequest({ ...addressFields, id: 'a1' }),
locals: staff()
} as any);
expect(result).toEqual({
success: false,
action: 'upsertAddress',
addressError: 'Address not found'
});
expect(mockAddressUpdate).not.toHaveBeenCalled();
});
});
describe('deleteAddress', () => {
it('rejects non-staff users', async () => {
const result = await actions.deleteAddress({
request: createFormRequest({ id: 'a1' }),
locals: createLocals(mockUser({ roleId: 4 }) as any)
} as any);
expect(result).toEqual({
success: false,
action: 'deleteAddress',
addressError: 'Not authorized'
});
expect(mockAddressDelete).not.toHaveBeenCalled();
});
it('deletes the address by id for staff', async () => {
mockAddressDelete.mockResolvedValue({});
const result = await actions.deleteAddress({
request: createFormRequest({ id: 'a1' }),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'deleteAddress', addressId: 'a1' });
expect(mockAddressDelete).toHaveBeenCalledWith({ where: { id: 'a1' } });
});
it('returns an error when id is missing', async () => {
const result = await actions.deleteAddress({
request: createFormRequest({}),
locals: staff()
} as any);
expect(result).toEqual({
success: false,
action: 'deleteAddress',
addressError: 'Missing address id'
});
});
});
});