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