283 lines
9.3 KiB
TypeScript
283 lines
9.3 KiB
TypeScript
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,
|
|
};
|
|
}
|