Stage M8
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { CustomerAddress, CustomerProfile } from '@sport/types';
|
||||
import type { CustomerAddressInput, UpdateProfileInput } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
|
||||
/** Hard cap on the address book. Not a business rule — an abuse ceiling. */
|
||||
const MAX_ADDRESSES = 20;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
private readonly logger = new Logger(CustomersService.name);
|
||||
|
||||
constructor(private readonly repository: CustomersRepository) {}
|
||||
|
||||
/**
|
||||
* Creates the customer record for a newly registered user, and adopts any
|
||||
* guest orders placed with the same email.
|
||||
*
|
||||
* Called by AuthService during registration — the one moment where matching
|
||||
* orders on email is safe, because the password was just set by whoever
|
||||
* controls that address.
|
||||
*/
|
||||
async provision(userId: string, email: string, acceptsMarketing: boolean): Promise<string> {
|
||||
const customer = await this.repository.create(userId, acceptsMarketing);
|
||||
|
||||
const adopted = await this.repository.adoptGuestOrders(customer.id, email);
|
||||
if (adopted > 0) {
|
||||
this.logger.log(`Adopted ${adopted} guest order(s) into customer ${customer.id}`);
|
||||
}
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer id behind a signed-in user, or null for a staff account.
|
||||
*
|
||||
* The one thing other modules need from here. Orders and checkout scope their
|
||||
* queries by `customerId`, and resolving it via this module keeps the
|
||||
* `customers` table owned by exactly one place.
|
||||
*/
|
||||
async resolveCustomerId(userId: string): Promise<string | null> {
|
||||
const row = await this.repository.findByUserId(userId);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// ---- Profile -------------------------------------------------------------
|
||||
|
||||
async getProfile(userId: string): Promise<CustomerProfile> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.user.email,
|
||||
// Nullable on `users` because staff accounts may be created without
|
||||
// them; a shopper always supplies both at registration, so an empty
|
||||
// string here means a legacy row rather than a normal state.
|
||||
firstName: row.user.firstName ?? '',
|
||||
lastName: row.user.lastName ?? '',
|
||||
phone: row.user.phone,
|
||||
acceptsMarketing: row.acceptsMarketing,
|
||||
// Date only. `toISOString()` would append a UTC time to a birthday and
|
||||
// shift it a day for anyone east of Greenwich — including every customer
|
||||
// this store has.
|
||||
dateOfBirth: row.dateOfBirth ? row.dateOfBirth.toISOString().slice(0, 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, input: UpdateProfileInput): Promise<CustomerProfile> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
|
||||
await this.repository.updateProfile(row.id, userId, {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone === undefined ? undefined : (input.phone ?? null),
|
||||
acceptsMarketing: input.acceptsMarketing,
|
||||
dateOfBirth:
|
||||
input.dateOfBirth === undefined
|
||||
? undefined
|
||||
: input.dateOfBirth
|
||||
? new Date(input.dateOfBirth)
|
||||
: null,
|
||||
});
|
||||
|
||||
return this.getProfile(userId);
|
||||
}
|
||||
|
||||
// ---- Addresses -----------------------------------------------------------
|
||||
|
||||
async listAddresses(userId: string): Promise<CustomerAddress[]> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
const addresses = await this.repository.findAddresses(row.id);
|
||||
|
||||
return addresses.map(toCustomerAddress);
|
||||
}
|
||||
|
||||
async createAddress(userId: string, input: CustomerAddressInput): Promise<CustomerAddress> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
|
||||
const existing = await this.repository.countAddresses(row.id);
|
||||
if (existing >= MAX_ADDRESSES) {
|
||||
throw AppException.conflict(`You can save at most ${MAX_ADDRESSES} addresses.`);
|
||||
}
|
||||
|
||||
// The first address is the default whether or not they asked — an address
|
||||
// book with no default makes checkout prefill nothing.
|
||||
const isFirst = existing === 0;
|
||||
|
||||
const created = await this.repository.transaction(async (tx) => {
|
||||
const address = await tx.address.create({
|
||||
data: {
|
||||
customerId: row.id,
|
||||
...toAddressData(input),
|
||||
isDefaultShipping: input.isDefaultShipping || isFirst,
|
||||
isDefaultBilling: input.isDefaultBilling || isFirst,
|
||||
},
|
||||
});
|
||||
|
||||
if (address.isDefaultShipping) {
|
||||
await this.repository.clearDefaults(tx, row.id, address.id, 'shipping');
|
||||
}
|
||||
if (address.isDefaultBilling) {
|
||||
await this.repository.clearDefaults(tx, row.id, address.id, 'billing');
|
||||
}
|
||||
|
||||
return address;
|
||||
});
|
||||
|
||||
return toCustomerAddress(created);
|
||||
}
|
||||
|
||||
async updateAddress(
|
||||
userId: string,
|
||||
addressId: string,
|
||||
input: CustomerAddressInput,
|
||||
): Promise<CustomerAddress> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
|
||||
const existing = await this.repository.findAddress(row.id, addressId);
|
||||
if (!existing) throw AppException.notFound('Address');
|
||||
|
||||
const updated = await this.repository.transaction(async (tx) => {
|
||||
const address = await tx.address.update({
|
||||
where: { id: addressId },
|
||||
data: {
|
||||
...toAddressData(input),
|
||||
isDefaultShipping: input.isDefaultShipping,
|
||||
isDefaultBilling: input.isDefaultBilling,
|
||||
},
|
||||
});
|
||||
|
||||
if (address.isDefaultShipping) {
|
||||
await this.repository.clearDefaults(tx, row.id, address.id, 'shipping');
|
||||
}
|
||||
if (address.isDefaultBilling) {
|
||||
await this.repository.clearDefaults(tx, row.id, address.id, 'billing');
|
||||
}
|
||||
|
||||
return address;
|
||||
});
|
||||
|
||||
return toCustomerAddress(updated);
|
||||
}
|
||||
|
||||
async deleteAddress(userId: string, addressId: string): Promise<void> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
|
||||
const existing = await this.repository.findAddress(row.id, addressId);
|
||||
if (!existing) throw AppException.notFound('Address');
|
||||
|
||||
await this.repository.deleteAddress(addressId);
|
||||
|
||||
/**
|
||||
* Promote another address if the default was just removed.
|
||||
*
|
||||
* Without this, deleting the default leaves a book full of addresses and
|
||||
* nothing prefilled at checkout — which reads as the address book being
|
||||
* broken rather than as a consequence of the delete.
|
||||
*/
|
||||
if (existing.isDefaultShipping || existing.isDefaultBilling) {
|
||||
const remaining = await this.repository.findAddresses(row.id);
|
||||
const next = remaining[0];
|
||||
|
||||
if (next) {
|
||||
await this.repository.updateAddress(next.id, {
|
||||
isDefaultShipping: next.isDefaultShipping || existing.isDefaultShipping,
|
||||
isDefaultBilling: next.isDefaultBilling || existing.isDefaultBilling,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The address checkout should prefill, if any. */
|
||||
async defaultShippingAddress(userId: string): Promise<CustomerAddress | null> {
|
||||
const addresses = await this.listAddresses(userId);
|
||||
return addresses.find((address) => address.isDefaultShipping) ?? addresses[0] ?? null;
|
||||
}
|
||||
|
||||
// ---- Wishlist ------------------------------------------------------------
|
||||
|
||||
async listWishlistProductIds(userId: string): Promise<string[]> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
const items = await this.repository.findWishlistProductIds(row.id);
|
||||
|
||||
return items.map((item) => item.productId);
|
||||
}
|
||||
|
||||
async addToWishlist(userId: string, productId: string): Promise<void> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
await this.repository.addToWishlist(row.id, productId);
|
||||
}
|
||||
|
||||
async removeFromWishlist(userId: string, productId: string): Promise<void> {
|
||||
const row = await this.requireCustomer(userId);
|
||||
await this.repository.removeFromWishlist(row.id, productId);
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A staff account has no customer profile.
|
||||
*
|
||||
* The token audience already keeps admins off these routes, so reaching here
|
||||
* without a profile means a genuinely inconsistent account rather than an
|
||||
* authorisation failure — 404 is the honest answer.
|
||||
*/
|
||||
private async requireCustomer(userId: string) {
|
||||
const row = await this.repository.findByUserId(userId);
|
||||
if (!row) throw AppException.notFound('Customer profile');
|
||||
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
type AddressRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
line1: string;
|
||||
line2: string | null;
|
||||
ward: string | null;
|
||||
district: string | null;
|
||||
province: string;
|
||||
countryCode: string;
|
||||
postalCode: string | null;
|
||||
isDefaultShipping: boolean;
|
||||
isDefaultBilling: boolean;
|
||||
};
|
||||
|
||||
function toCustomerAddress(row: AddressRow): CustomerAddress {
|
||||
return {
|
||||
id: row.id,
|
||||
fullName: row.fullName,
|
||||
phone: row.phone,
|
||||
line1: row.line1,
|
||||
line2: row.line2,
|
||||
ward: row.ward,
|
||||
district: row.district,
|
||||
province: row.province,
|
||||
countryCode: row.countryCode,
|
||||
postalCode: row.postalCode,
|
||||
isDefaultShipping: row.isDefaultShipping,
|
||||
isDefaultBilling: row.isDefaultBilling,
|
||||
};
|
||||
}
|
||||
|
||||
function toAddressData(input: CustomerAddressInput) {
|
||||
return {
|
||||
fullName: input.fullName,
|
||||
phone: input.phone,
|
||||
line1: input.line1,
|
||||
line2: input.line2 ?? null,
|
||||
ward: input.ward ?? null,
|
||||
district: input.district ?? null,
|
||||
province: input.province,
|
||||
countryCode: input.countryCode,
|
||||
postalCode: input.postalCode ?? null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user