Files
web_sport/apps/api/src/modules/customers/customers.service.ts
T
2026-08-13 23:20:23 +07:00

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