diff --git a/src/addresses/address.schema.ts b/src/addresses/address.schema.ts new file mode 100644 index 0000000..bfd9665 --- /dev/null +++ b/src/addresses/address.schema.ts @@ -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; diff --git a/src/addresses/address.store.ts b/src/addresses/address.store.ts new file mode 100644 index 0000000..6f5d2e5 --- /dev/null +++ b/src/addresses/address.store.ts @@ -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, + ); + }); + } +} diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts new file mode 100644 index 0000000..3c1c035 --- /dev/null +++ b/src/addresses/addresses.controller.ts @@ -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); + } +} diff --git a/src/addresses/addresses.module.ts b/src/addresses/addresses.module.ts new file mode 100644 index 0000000..4b7593a --- /dev/null +++ b/src/addresses/addresses.module.ts @@ -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 {} diff --git a/test/addresses.spec.ts b/test/addresses.spec.ts new file mode 100644 index 0000000..0121e52 --- /dev/null +++ b/test/addresses.spec.ts @@ -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); + }); +});