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 { 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 { const row = await this.repository.findByUserId(userId); return row?.id ?? null; } // ---- Profile ------------------------------------------------------------- async getProfile(userId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { const addresses = await this.listAddresses(userId); return addresses.find((address) => address.isDefaultShipping) ?? addresses[0] ?? null; } // ---- Wishlist ------------------------------------------------------------ async listWishlistProductIds(userId: string): Promise { 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 { const row = await this.requireCustomer(userId); await this.repository.addToWishlist(row.id, productId); } async removeFromWishlist(userId: string, productId: string): Promise { 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, }; }