feat/catalog-inventory #2
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { text } from '../common/input';
|
||||||
|
export const addressSchema = z
|
||||||
|
.object({
|
||||||
|
recipient: text(160),
|
||||||
|
line1: text(200),
|
||||||
|
line2: text(200, 0).default(''),
|
||||||
|
city: text(100),
|
||||||
|
region: text(100),
|
||||||
|
postalCode: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(20)
|
||||||
|
.regex(/^[A-Za-z0-9 -]+$/),
|
||||||
|
countryCode: z
|
||||||
|
.string()
|
||||||
|
.toUpperCase()
|
||||||
|
.regex(/^[A-Z]{2}$/),
|
||||||
|
phone: z.string().regex(/^\+[1-9]\d{6,14}$/),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type AddressInput = z.infer<typeof addressSchema>;
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
|
import { AccessStore } from '../identity/access.store';
|
||||||
|
import { recordAudit } from '../identity/audit';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import type { AddressInput } from './address.schema';
|
||||||
|
|
||||||
|
async function ensureDefault(tx: Prisma.TransactionClient, userId: string) {
|
||||||
|
if (await tx.address.count({ where: { userId, isDefault: true } })) return;
|
||||||
|
const first = await tx.address.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
if (first)
|
||||||
|
await tx.address.update({
|
||||||
|
where: { id: first.id },
|
||||||
|
data: { isDefault: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class AddressStore {
|
||||||
|
constructor(
|
||||||
|
private readonly db: DatabaseService,
|
||||||
|
private readonly access: AccessStore,
|
||||||
|
) {}
|
||||||
|
list(actor: Principal) {
|
||||||
|
return this.db.address.findMany({
|
||||||
|
where: { userId: actor.userId, organizationId: actor.organizationId },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
save(actor: Principal, input: AddressInput, id?: string) {
|
||||||
|
return this.access.mutate(actor, null, async (tx) => {
|
||||||
|
const where = {
|
||||||
|
userId: actor.userId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
};
|
||||||
|
if (id && !(await tx.address.findFirst({ where: { ...where, id } })))
|
||||||
|
throw new AppError('ADDRESS_NOT_FOUND');
|
||||||
|
if (!id && (await tx.address.count({ where })) >= 20)
|
||||||
|
throw new AppError('ADDRESS_LIMIT');
|
||||||
|
if (input.isDefault)
|
||||||
|
await tx.address.updateMany({ where, data: { isDefault: false } });
|
||||||
|
const address = id
|
||||||
|
? await tx.address.update({ where: { id }, data: input })
|
||||||
|
: await tx.address.create({ data: { ...where, ...input } });
|
||||||
|
await ensureDefault(tx, actor.userId);
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
id ? 'address.updated' : 'address.created',
|
||||||
|
address.id,
|
||||||
|
);
|
||||||
|
return tx.address.findUniqueOrThrow({ where: { id: address.id } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
remove(actor: Principal, id: string) {
|
||||||
|
return this.access.mutate(actor, null, async (tx) => {
|
||||||
|
const removed = await tx.address.deleteMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: actor.userId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!removed.count) throw new AppError('ADDRESS_NOT_FOUND');
|
||||||
|
await ensureDefault(tx, actor.userId);
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
'address.deleted',
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SchemaPipe } from '../common/validation.pipe';
|
||||||
|
import { CurrentPrincipal } from '../identity/access.decorator';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { AddressStore } from './address.store';
|
||||||
|
import { addressSchema, type AddressInput } from './address.schema';
|
||||||
|
|
||||||
|
@Controller('addresses')
|
||||||
|
export class AddressesController {
|
||||||
|
constructor(private readonly addresses: AddressStore) {}
|
||||||
|
@Get()
|
||||||
|
list(@CurrentPrincipal() actor: Principal) {
|
||||||
|
return this.addresses.list(actor);
|
||||||
|
}
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Body(new SchemaPipe(addressSchema)) input: AddressInput,
|
||||||
|
) {
|
||||||
|
return this.addresses.save(actor, input);
|
||||||
|
}
|
||||||
|
@Put(':id')
|
||||||
|
update(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(addressSchema)) input: AddressInput,
|
||||||
|
) {
|
||||||
|
return this.addresses.save(actor, input, id);
|
||||||
|
}
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
remove(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.addresses.remove(actor, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../database/database.module';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
import { AddressStore } from './address.store';
|
||||||
|
import { AddressesController } from './addresses.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, IdentityModule],
|
||||||
|
providers: [AddressStore],
|
||||||
|
controllers: [AddressesController],
|
||||||
|
})
|
||||||
|
export class AddressesModule {}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { addressInput, secondActor } from './helpers/commerce';
|
||||||
|
|
||||||
|
describe('private address book', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx?.close();
|
||||||
|
});
|
||||||
|
const auth = { type: 'bearer' as const };
|
||||||
|
it('selects one default and selects a replacement after deletion', async () => {
|
||||||
|
const first = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/addresses')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send(addressInput)
|
||||||
|
.expect(201);
|
||||||
|
expect(first.body.isDefault).toBe(true);
|
||||||
|
const second = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/addresses')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...addressInput, line1: '13 Test Road', isDefault: true })
|
||||||
|
.expect(201);
|
||||||
|
const list = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/addresses')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
list.body.filter((row: { isDefault: boolean }) => row.isDefault),
|
||||||
|
).toHaveLength(1);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/addresses/${second.body.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...addressInput, recipient: 'Changed', isDefault: true })
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.delete(`/api/v1/addresses/${second.body.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(204);
|
||||||
|
expect(
|
||||||
|
await ctx.db.address.findUnique({ where: { id: first.body.id } }),
|
||||||
|
).toMatchObject({ isDefault: true });
|
||||||
|
});
|
||||||
|
it('denies access to another user address, including within the same organization', async () => {
|
||||||
|
const owner = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/addresses')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send(addressInput)
|
||||||
|
.expect(201);
|
||||||
|
const actor = await secondActor(ctx);
|
||||||
|
const own = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/addresses')
|
||||||
|
.auth(actor.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(own.body).toEqual([]);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/addresses/${owner.body.id}`)
|
||||||
|
.auth(actor.token, auth)
|
||||||
|
.send(addressInput)
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.delete(`/api/v1/addresses/${owner.body.id}`)
|
||||||
|
.auth(actor.token, auth)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('rejects invalid contact fields and user-id injection', async () => {
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/addresses')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...addressInput, phone: '123' })
|
||||||
|
.expect(400);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/addresses')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...addressInput, userId: ctx.owner.userId })
|
||||||
|
.expect(400);
|
||||||
|
await ctx.api().get('/api/v1/addresses').expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue