feat(addresses): manage private customer addresses and defaults

This commit is contained in:
mihir 2026-09-10 11:23:23 +05:30
parent 14900fe83e
commit 4457cfdf1a
5 changed files with 256 additions and 0 deletions

View File

@ -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>;

View File

@ -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,
);
});
}
}

View File

@ -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);
}
}

View File

@ -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 {}

91
test/addresses.spec.ts Normal file
View File

@ -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);
});
});