Fix staff(+) unable to edit or delete addresses
This commit is contained in:
@@ -22,6 +22,7 @@ const ProfileSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AddressSchema = z.object({
|
const AddressSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
profileId: z.string().min(1),
|
profileId: z.string().min(1),
|
||||||
street: z.string().min(1, 'Street is required'),
|
street: z.string().min(1, 'Street is required'),
|
||||||
city: z.string().min(1, 'City is required'),
|
city: z.string().min(1, 'City is required'),
|
||||||
@@ -147,8 +148,14 @@ export const actions = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
|
const {
|
||||||
form.data;
|
id,
|
||||||
|
profileId,
|
||||||
|
isDefaultMailing,
|
||||||
|
isDefaultBilling,
|
||||||
|
isDefaultShipping,
|
||||||
|
...addressData
|
||||||
|
} = form.data;
|
||||||
|
|
||||||
if (!profileId) {
|
if (!profileId) {
|
||||||
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
|
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
|
||||||
@@ -181,7 +188,36 @@ export const actions = {
|
|||||||
isDefaultShipping
|
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 });
|
const created = await prisma.address.create({ data: payload });
|
||||||
return { success: true, action: 'upsertAddress', address: created };
|
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>;
|
fieldErrors?: Record<string, string>;
|
||||||
addressError?: string;
|
addressError?: string;
|
||||||
address?: Record<string, unknown>;
|
address?: Record<string, unknown>;
|
||||||
|
addressId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
let addressError = $state('');
|
let addressError = $state('');
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
let addressFormOpen = $state(false);
|
let addressFormOpen = $state(false);
|
||||||
|
let editingAddressId = $state<string | null>(null);
|
||||||
let addresses = $state<Record<string, unknown>[]>([]);
|
let addresses = $state<Record<string, unknown>[]>([]);
|
||||||
|
|
||||||
let addrStreet = $state('');
|
let addrStreet = $state('');
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
let lastForm = $state.raw<ActionResult>(untrack(() => form ?? null));
|
let lastForm = $state.raw<ActionResult>(untrack(() => form ?? null));
|
||||||
|
|
||||||
function resetAddressFields() {
|
function resetAddressFields() {
|
||||||
|
editingAddressId = null;
|
||||||
addrStreet = '';
|
addrStreet = '';
|
||||||
addrCity = '';
|
addrCity = '';
|
||||||
addrState = '';
|
addrState = '';
|
||||||
@@ -89,7 +92,11 @@
|
|||||||
switch (form.action) {
|
switch (form.action) {
|
||||||
case 'upsertAddress':
|
case 'upsertAddress':
|
||||||
if (form.success && form.address) {
|
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;
|
addressFormOpen = false;
|
||||||
addressError = '';
|
addressError = '';
|
||||||
successMessage = 'Address saved';
|
successMessage = 'Address saved';
|
||||||
@@ -99,6 +106,16 @@
|
|||||||
addressError = form.addressError ?? '';
|
addressError = form.addressError ?? '';
|
||||||
}
|
}
|
||||||
break;
|
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':
|
case 'upsert':
|
||||||
default:
|
default:
|
||||||
if (form.success) {
|
if (form.success) {
|
||||||
@@ -119,6 +136,21 @@
|
|||||||
addressError = '';
|
addressError = '';
|
||||||
if (addressFormOpen) resetAddressFields();
|
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>
|
</script>
|
||||||
|
|
||||||
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
|
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
|
||||||
@@ -172,6 +204,9 @@
|
|||||||
{#if addressFormOpen}
|
{#if addressFormOpen}
|
||||||
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
|
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
|
||||||
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
|
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
|
||||||
|
{#if editingAddressId}
|
||||||
|
<input type="hidden" name="id" value={editingAddressId} />
|
||||||
|
{/if}
|
||||||
{#if addressError}
|
{#if addressError}
|
||||||
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
{addressError}
|
{addressError}
|
||||||
@@ -262,7 +297,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -283,6 +318,7 @@
|
|||||||
<th scope="col" class="px-3 py-2">M</th>
|
<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">B</th>
|
||||||
<th scope="col" class="px-3 py-2">S</th>
|
<th scope="col" class="px-3 py-2">S</th>
|
||||||
|
<th scope="col" class="px-3 py-2">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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.isDefaultMailing ? '✓' : ''}</td>
|
||||||
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</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">{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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ vi.mock('$lib/db', () => ({
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
update: 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 mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const mockUserFindFirst = prisma.user.findFirst 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 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 = {
|
const validFields = {
|
||||||
email: 'jane@example.com',
|
email: 'jane@example.com',
|
||||||
@@ -242,4 +252,132 @@ describe('profiles admin page server', () => {
|
|||||||
expect(result).toEqual({ success: false, error: 'Missing id' });
|
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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user