Stage M8
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- NOTE: Prisma's diff wanted to DROP INDEX "search_documents_document_idx" and
|
||||
-- ALTER "search_documents"."document" DROP DEFAULT here. Removed on purpose —
|
||||
-- see the M6 migration: `document` is a GENERATED tsvector column Prisma cannot
|
||||
-- express, so it proposes undoing it in every migration. Applying it destroys
|
||||
-- full-text search.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wishlist_items" (
|
||||
"customer_id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("customer_id","product_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wishlist_items_customer_id_created_at_idx" ON "wishlist_items"("customer_id", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -210,9 +210,10 @@ model Customer {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
addresses Address[]
|
||||
orders Order[]
|
||||
wishlist WishlistItem[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
@@ -525,6 +526,7 @@ model Product {
|
||||
|
||||
searchDocuments SearchDocument[]
|
||||
reviews Review[]
|
||||
wishlistedBy WishlistItem[]
|
||||
discounts DiscountProduct[]
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
@@ -1346,3 +1348,26 @@ model ContentEntryTranslation {
|
||||
@@unique([locale, slug])
|
||||
@@map("content_entry_translations")
|
||||
}
|
||||
|
||||
/// One saved product, per customer.
|
||||
///
|
||||
/// Deliberately keyed on the *product*, not a variant. A shopper saving a
|
||||
/// jacket is saying "this one, later" — not "this one in black, size M". Saving
|
||||
/// a variant would mean a wishlist entry silently dies when a colourway is
|
||||
/// discontinued, and would make the same jacket appear five times.
|
||||
model WishlistItem {
|
||||
customerId String @map("customer_id") @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// The composite key IS the uniqueness rule: adding a product twice is a
|
||||
/// no-op upsert rather than a duplicate row or an error the client must
|
||||
/// handle. Double-tapping a heart icon is not a failure.
|
||||
@@id([customerId, productId])
|
||||
@@index([customerId, createdAt])
|
||||
@@map("wishlist_items")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ import {
|
||||
type SessionSummary,
|
||||
type TokenAudience,
|
||||
} from '@sport/types';
|
||||
import { loginSchema, type LoginInput } from '@sport/validation';
|
||||
import {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
type LoginInput,
|
||||
type RegisterInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
@@ -61,6 +66,21 @@ export class AuthController {
|
||||
return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Create a shopper account and sign in' })
|
||||
async register(
|
||||
@Body(new ZodValidationPipe(registerSchema)) body: RegisterInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<LoginResult> {
|
||||
const { result, session } = await this.authService.register(body, contextOf(request));
|
||||
// Storefront audience only — there is no admin equivalent of this route.
|
||||
this.writeSession(response, TOKEN_AUDIENCES.STOREFRONT, session);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { CustomersModule } from '@/modules/customers/customers.module';
|
||||
import { UsersModule } from '@/modules/users/users.module';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -24,7 +25,7 @@ import { TokenService } from './token.service';
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), UsersModule],
|
||||
imports: [JwtModule.register({}), UsersModule, CustomersModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
SYSTEM_ROLES,
|
||||
TOKEN_AUDIENCES,
|
||||
isBackOfficeUser,
|
||||
type CurrentUser,
|
||||
@@ -11,10 +12,11 @@ import {
|
||||
type TokenAudience,
|
||||
type UserType,
|
||||
} from '@sport/types';
|
||||
import type { LoginInput } from '@sport/validation';
|
||||
import type { LoginInput, RegisterInput } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PasswordService } from '@/common/security/password.service';
|
||||
import { CustomersService } from '@/modules/customers/public';
|
||||
import { UsersService, type AuthUserRow } from '@/modules/users/public';
|
||||
|
||||
import { AuthRepository } from './auth.repository';
|
||||
@@ -43,6 +45,7 @@ export class AuthService {
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly repository: AuthRepository,
|
||||
private readonly throttle: LoginThrottleService,
|
||||
private readonly customersService: CustomersService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -53,6 +56,69 @@ export class AuthService {
|
||||
* the login form into a user-enumeration oracle, and the timing is equalised
|
||||
* for the same reason.
|
||||
*/
|
||||
/**
|
||||
* Creates a shopper account and signs them straight in.
|
||||
*
|
||||
* Registration is a storefront-only action: there is deliberately no way to
|
||||
* self-register a staff account, so the type is pinned to CUSTOMER here
|
||||
* rather than taken from the request. The CUSTOMER role is resolved by key
|
||||
* because roles are data — a SUPER_ADMIN can edit them, so the id is not a
|
||||
* stable reference and must never be hard-coded.
|
||||
*
|
||||
* Signing in immediately rather than bouncing to a login form: the password
|
||||
* was typed seconds ago, and making someone retype it to reach the account
|
||||
* they just created is friction with no security benefit.
|
||||
*/
|
||||
async register(
|
||||
input: RegisterInput,
|
||||
context: RequestContext,
|
||||
): Promise<{ result: LoginResult; session: IssuedSession }> {
|
||||
const roleId = await this.usersService.findRoleIdByKey(SYSTEM_ROLES.CUSTOMER);
|
||||
if (!roleId) {
|
||||
// A seeding failure, not a client error — fail loudly rather than create
|
||||
// an account with no permissions that breaks mysteriously later.
|
||||
throw new Error('The customer role is missing; the database is not seeded.');
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordService.hash(input.password);
|
||||
|
||||
/**
|
||||
* Throws 409 if the email is taken, and deliberately says so.
|
||||
*
|
||||
* Softening this to a generic message protects nothing — the login form
|
||||
* already reveals which addresses exist — while leaving an honest person
|
||||
* stuck on a form that will not accept them and will not say why.
|
||||
*/
|
||||
const created = await this.usersService.createCustomer(
|
||||
{
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone,
|
||||
roleIds: [roleId],
|
||||
},
|
||||
passwordHash,
|
||||
);
|
||||
|
||||
await this.customersService.provision(created.id, input.email, input.acceptsMarketing);
|
||||
|
||||
const user = await this.usersService.findForAuthByEmail(input.email);
|
||||
if (!user) throw new Error('Account vanished immediately after creation.');
|
||||
|
||||
const session = await this.startSession(user, TOKEN_AUDIENCES.STOREFRONT, context);
|
||||
|
||||
this.logger.log(`Registered customer ${created.id}`);
|
||||
|
||||
return {
|
||||
result: {
|
||||
user: this.usersService.toCurrentUser(user),
|
||||
accessToken: session.accessToken,
|
||||
accessTokenExpiresAt: session.accessTokenExpiresAt.toISOString(),
|
||||
},
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
async login(
|
||||
input: LoginInput,
|
||||
audience: TokenAudience,
|
||||
|
||||
@@ -38,13 +38,32 @@ export class AccessTokenGuard implements CanActivate {
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = extractBearerToken(request);
|
||||
|
||||
/**
|
||||
* A public route still *recognises* a caller who brought a token.
|
||||
*
|
||||
* Checkout is the reason: it must work for guests, so it cannot require
|
||||
* authentication, but an order placed by a signed-in shopper has to end up
|
||||
* attached to their account. Without this, the only ways to do that are to
|
||||
* trust a customer id from the request body — which is an
|
||||
* account-takeover primitive — or to re-verify the token by hand in the
|
||||
* controller, duplicating exactly the logic below.
|
||||
*
|
||||
* The route stays public in every case: a missing, malformed or expired
|
||||
* token leaves `request.actor` undefined and the request proceeds. Nothing
|
||||
* here can turn a public route into a protected one, or vice versa.
|
||||
*/
|
||||
if (isPublic) {
|
||||
if (token) {
|
||||
const claims = await this.verifyQuietly(token);
|
||||
if (claims) request.actor = toActor(claims);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw AppException.unauthenticated();
|
||||
}
|
||||
@@ -74,17 +93,31 @@ export class AccessTokenGuard implements CanActivate {
|
||||
throw AppException.forbidden('This credential cannot be used here.');
|
||||
}
|
||||
|
||||
const actor: AuthenticatedActor = {
|
||||
userId: claims.sub,
|
||||
userType: claims.type,
|
||||
audience: claims.aud,
|
||||
permissions: claims.permissions ?? [],
|
||||
sessionId: claims.sid,
|
||||
};
|
||||
|
||||
request.actor = actor;
|
||||
request.actor = toActor(claims);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Verifies a token for the optional path, where failure simply means "guest". */
|
||||
private async verifyQuietly(token: string): Promise<AccessTokenClaims | null> {
|
||||
try {
|
||||
return await this.jwtService.verifyAsync<AccessTokenClaims>(token, {
|
||||
secret: this.config.auth.accessSecret,
|
||||
issuer: this.config.auth.issuer,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toActor(claims: AccessTokenClaims): AuthenticatedActor {
|
||||
return {
|
||||
userId: claims.sub,
|
||||
userType: claims.type,
|
||||
audience: claims.aud,
|
||||
permissions: claims.permissions ?? [],
|
||||
sessionId: claims.sid,
|
||||
};
|
||||
}
|
||||
|
||||
function extractBearerToken(request: Request): string | null {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type { Cart, Locale, Order } from '@sport/types';
|
||||
import { TOKEN_AUDIENCES, type Cart, type Locale, type Order } from '@sport/types';
|
||||
import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation';
|
||||
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
@@ -23,13 +23,17 @@ import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public';
|
||||
import { CustomersService } from '@/modules/customers/public';
|
||||
import { OrdersService } from '@/modules/orders/public';
|
||||
|
||||
import { CheckoutService } from './checkout.service';
|
||||
|
||||
/**
|
||||
* Public because guest checkout is the default. Customer accounts arrive in M8
|
||||
* and will attach an order to a customer, not gate the ability to place one.
|
||||
* Public because guest checkout is the default.
|
||||
*
|
||||
* An account attaches an order to a customer; it never gates the ability to
|
||||
* place one. A signed-in shopper is recognised through the optional branch of
|
||||
* AccessTokenGuard, so the same endpoint serves both without a second route.
|
||||
*/
|
||||
@ApiTags('checkout')
|
||||
@Public()
|
||||
@@ -38,6 +42,7 @@ export class CheckoutController {
|
||||
constructor(
|
||||
private readonly service: CheckoutService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly customers: CustomersService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@@ -81,7 +86,21 @@ export class CheckoutController {
|
||||
}
|
||||
|
||||
const { token } = resolveCartToken(request);
|
||||
const order = await this.service.placeOrder(token, key, body, locale);
|
||||
|
||||
/**
|
||||
* A signed-in shopper's order is attached to their account; a guest's is
|
||||
* not. The actor is optional here because checkout must work without an
|
||||
* account — see the public branch of AccessTokenGuard. The audience check
|
||||
* matters: an admin token must never file an order into a customer's
|
||||
* history, and `resolveCustomerId` would return null for staff anyway.
|
||||
*/
|
||||
const actor = request.actor;
|
||||
const customerId =
|
||||
actor && actor.audience === TOKEN_AUDIENCES.STOREFRONT
|
||||
? await this.customers.resolveCustomerId(actor.userId)
|
||||
: null;
|
||||
|
||||
const order = await this.service.placeOrder(token, key, body, locale, customerId);
|
||||
|
||||
// The bag is gone, so the cookie naming it should go too — otherwise the
|
||||
// next visit reads an empty cart under a stale token forever.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CartsModule } from '@/modules/carts/carts.module';
|
||||
import { CustomersModule } from '@/modules/customers/customers.module';
|
||||
import { OrdersModule } from '@/modules/orders/orders.module';
|
||||
import { PromotionsModule } from '@/modules/promotions/promotions.module';
|
||||
|
||||
@@ -15,7 +16,7 @@ import { CheckoutService } from './checkout.service';
|
||||
* being smeared across the two sides. Payment providers (M9) attach here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [CartsModule, OrdersModule, PromotionsModule],
|
||||
imports: [CartsModule, OrdersModule, PromotionsModule, CustomersModule],
|
||||
controllers: [CheckoutController],
|
||||
providers: [CheckoutService],
|
||||
exports: [CheckoutService],
|
||||
|
||||
@@ -70,6 +70,14 @@ export class CheckoutService {
|
||||
idempotencyKey: string,
|
||||
input: PlaceOrderInput,
|
||||
locale: Locale,
|
||||
/**
|
||||
* Set when a signed-in shopper checks out; null for a guest.
|
||||
*
|
||||
* Resolved from the session by the controller, never from the request body
|
||||
* — a client-supplied customer id would let anyone file an order into
|
||||
* somebody else's history.
|
||||
*/
|
||||
customerId: string | null = null,
|
||||
): Promise<Order> {
|
||||
const key = CACHE_KEYS.idempotency('checkout', idempotencyKey);
|
||||
const claimed = await this.redis.setIfAbsent(key, {}, CACHE_TTL.idempotency);
|
||||
@@ -86,7 +94,7 @@ export class CheckoutService {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.place(cartToken, key, input, locale);
|
||||
return await this.place(cartToken, key, input, locale, customerId);
|
||||
} catch (error) {
|
||||
// Release, so the shopper can fix whatever went wrong and try again.
|
||||
await this.redis.delete(key);
|
||||
@@ -99,6 +107,7 @@ export class CheckoutService {
|
||||
idempotencyCacheKey: string,
|
||||
input: PlaceOrderInput,
|
||||
locale: Locale,
|
||||
customerId: string | null,
|
||||
): Promise<Order> {
|
||||
const cart = await this.carts.resolveForCheckout(cartToken, locale);
|
||||
|
||||
@@ -166,6 +175,7 @@ export class CheckoutService {
|
||||
*/
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
customerId,
|
||||
email: input.email,
|
||||
phone: input.shippingAddress.phone,
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
TOKEN_AUDIENCES,
|
||||
type AuthenticatedActor,
|
||||
type CustomerAddress,
|
||||
type CustomerProfile,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
customerAddressSchema,
|
||||
updateProfileSchema,
|
||||
wishlistItemSchema,
|
||||
type CustomerAddressInput,
|
||||
type UpdateProfileInput,
|
||||
type WishlistItemInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import { RequireAudience } from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
/**
|
||||
* The signed-in shopper's own account.
|
||||
*
|
||||
* Every route here is scoped to `actor.userId` and takes no customer id from
|
||||
* the client — there is deliberately no `GET /customers/:id`. That is what
|
||||
* makes horizontal privilege escalation impossible by construction rather than
|
||||
* by remembering to check ownership in each handler.
|
||||
*
|
||||
* `RequireAudience(STOREFRONT)` keeps admin tokens out: a back-office token is
|
||||
* for back-office endpoints, and reusing it here would blur which surface an
|
||||
* action came from in the audit trail.
|
||||
*/
|
||||
@ApiTags('account')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.STOREFRONT)
|
||||
@Controller('account')
|
||||
export class CustomersController {
|
||||
constructor(private readonly service: CustomersService) {}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'The signed-in shopper’s profile' })
|
||||
getProfile(@CurrentActor() actor: AuthenticatedActor): Promise<CustomerProfile> {
|
||||
return this.service.getProfile(actor.userId);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update name, phone, birthday or marketing consent' })
|
||||
updateProfile(
|
||||
@Body(new ZodValidationPipe(updateProfileSchema)) body: UpdateProfileInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<CustomerProfile> {
|
||||
return this.service.updateProfile(actor.userId, body);
|
||||
}
|
||||
|
||||
@Get('addresses')
|
||||
@ApiOperation({ summary: 'Address book, defaults first' })
|
||||
listAddresses(@CurrentActor() actor: AuthenticatedActor): Promise<CustomerAddress[]> {
|
||||
return this.service.listAddresses(actor.userId);
|
||||
}
|
||||
|
||||
@Post('addresses')
|
||||
@ApiOperation({ summary: 'Save an address' })
|
||||
createAddress(
|
||||
@Body(new ZodValidationPipe(customerAddressSchema)) body: CustomerAddressInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<CustomerAddress> {
|
||||
return this.service.createAddress(actor.userId, body);
|
||||
}
|
||||
|
||||
@Patch('addresses/:id')
|
||||
@ApiOperation({ summary: 'Update a saved address' })
|
||||
updateAddress(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(customerAddressSchema)) body: CustomerAddressInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<CustomerAddress> {
|
||||
return this.service.updateAddress(actor.userId, id, body);
|
||||
}
|
||||
|
||||
@Delete('addresses/:id')
|
||||
@ApiOperation({ summary: 'Remove a saved address' })
|
||||
deleteAddress(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
|
||||
return this.service.deleteAddress(actor.userId, id);
|
||||
}
|
||||
|
||||
@Get('wishlist')
|
||||
@ApiOperation({ summary: 'Saved product ids, newest first' })
|
||||
listWishlist(@CurrentActor() actor: AuthenticatedActor): Promise<string[]> {
|
||||
return this.service.listWishlistProductIds(actor.userId);
|
||||
}
|
||||
|
||||
@Post('wishlist')
|
||||
@ApiOperation({ summary: 'Save a product. Idempotent.' })
|
||||
async addToWishlist(
|
||||
@Body(new ZodValidationPipe(wishlistItemSchema)) body: WishlistItemInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<{ ok: true }> {
|
||||
await this.service.addToWishlist(actor.userId, body.productId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Delete('wishlist/:productId')
|
||||
@ApiOperation({ summary: 'Remove a saved product' })
|
||||
async removeFromWishlist(
|
||||
@Param('productId') productId: string,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<{ ok: true }> {
|
||||
await this.service.removeFromWishlist(actor.userId, productId);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersRepository } from './customers.repository';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
/**
|
||||
* CustomersModule — boundary declared, implementation pending.
|
||||
* CustomersModule — owns `customers`, `addresses` and `wishlist_items`.
|
||||
*
|
||||
* Owns (exclusively): `customers`, `addresses`
|
||||
* Shopper profiles are separate from `users` so customer PII can later live
|
||||
* under a stricter access policy without touching staff accounts. The split
|
||||
* costs one join and buys the ability to treat the two differently.
|
||||
*
|
||||
* Shopper profiles and address book. Separate from `users` so customer PII can later live under a stricter access policy without touching staff accounts.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* customers.module.ts wiring only
|
||||
* customers.controller.ts HTTP surface, no logic
|
||||
* customers.service.ts business rules
|
||||
* customers.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Exported for AuthModule, which provisions a customer record during
|
||||
* registration — the only place another module writes here.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, CustomersRepository],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
/** The only file in this module that touches Prisma. */
|
||||
@Injectable()
|
||||
export class CustomersRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ---- Profile -------------------------------------------------------------
|
||||
|
||||
findByUserId(userId: string) {
|
||||
return this.prisma.customer.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
acceptsMarketing: true,
|
||||
dateOfBirth: true,
|
||||
user: { select: { email: true, firstName: true, lastName: true, phone: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the customer profile that hangs off a user account.
|
||||
*
|
||||
* Separate from `users` on purpose (see the module doc): shopper PII can
|
||||
* later move under a stricter access policy without touching staff accounts.
|
||||
*/
|
||||
create(userId: string, acceptsMarketing: boolean) {
|
||||
return this.prisma.customer.create({
|
||||
data: { userId, acceptsMarketing },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
customerId: string,
|
||||
userId: string,
|
||||
data: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string | null;
|
||||
acceptsMarketing?: boolean;
|
||||
dateOfBirth?: Date | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
// Name and phone live on `users`, marketing consent and birthday on
|
||||
// `customers`. One transaction so a half-applied profile is impossible.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(data.firstName === undefined ? {} : { firstName: data.firstName }),
|
||||
...(data.lastName === undefined ? {} : { lastName: data.lastName }),
|
||||
...(data.phone === undefined ? {} : { phone: data.phone }),
|
||||
},
|
||||
}),
|
||||
this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
...(data.acceptsMarketing === undefined
|
||||
? {}
|
||||
: { acceptsMarketing: data.acceptsMarketing }),
|
||||
...(data.dateOfBirth === undefined ? {} : { dateOfBirth: data.dateOfBirth }),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Addresses -----------------------------------------------------------
|
||||
|
||||
findAddresses(customerId: string) {
|
||||
return this.prisma.address.findMany({
|
||||
where: { customerId },
|
||||
// Defaults first, then newest. An address book exists to be picked from,
|
||||
// and the one they always use should not be third in the list.
|
||||
orderBy: [{ isDefaultShipping: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
findAddress(customerId: string, addressId: string) {
|
||||
// Scoped by customer, always: an address id alone must never be enough to
|
||||
// read somebody else's address.
|
||||
return this.prisma.address.findFirst({ where: { id: addressId, customerId } });
|
||||
}
|
||||
|
||||
createAddress(data: Prisma.AddressUncheckedCreateInput) {
|
||||
return this.prisma.address.create({ data });
|
||||
}
|
||||
|
||||
updateAddress(addressId: string, data: Prisma.AddressUncheckedUpdateInput) {
|
||||
return this.prisma.address.update({ where: { id: addressId }, data });
|
||||
}
|
||||
|
||||
deleteAddress(addressId: string) {
|
||||
return this.prisma.address.delete({ where: { id: addressId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the default flag on every other address.
|
||||
*
|
||||
* "Default" is a property of the set, not of the row, and the database cannot
|
||||
* express "at most one true per customer" without a partial unique index that
|
||||
* would then reject the intermediate state of a swap. So it is maintained
|
||||
* here, in the same transaction as the write that sets it.
|
||||
*/
|
||||
clearDefaults(
|
||||
tx: Prisma.TransactionClient,
|
||||
customerId: string,
|
||||
exceptId: string | null,
|
||||
kind: 'shipping' | 'billing',
|
||||
) {
|
||||
return tx.address.updateMany({
|
||||
where: { customerId, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
||||
data: kind === 'shipping' ? { isDefaultShipping: false } : { isDefaultBilling: false },
|
||||
});
|
||||
}
|
||||
|
||||
transaction<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||
return this.prisma.$transaction(fn);
|
||||
}
|
||||
|
||||
countAddresses(customerId: string): Promise<number> {
|
||||
return this.prisma.address.count({ where: { customerId } });
|
||||
}
|
||||
|
||||
// ---- Wishlist ------------------------------------------------------------
|
||||
|
||||
findWishlistProductIds(customerId: string) {
|
||||
return this.prisma.wishlistItem.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { productId: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Idempotent: double-tapping a heart is not an error. */
|
||||
addToWishlist(customerId: string, productId: string) {
|
||||
return this.prisma.wishlistItem.upsert({
|
||||
where: { customerId_productId: { customerId, productId } },
|
||||
create: { customerId, productId },
|
||||
update: {},
|
||||
select: { productId: true },
|
||||
});
|
||||
}
|
||||
|
||||
removeFromWishlist(customerId: string, productId: string) {
|
||||
return this.prisma.wishlistItem.deleteMany({ where: { customerId, productId } });
|
||||
}
|
||||
|
||||
// ---- Guest order adoption ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attaches past guest orders placed with this email to the new account.
|
||||
*
|
||||
* Matched on email because that is the only link a guest checkout leaves
|
||||
* behind. Safe *only* because it runs at registration, where the email has
|
||||
* just been proven to be reachable by whoever set the password — the same
|
||||
* assumption the whole password-reset flow rests on.
|
||||
*/
|
||||
adoptGuestOrders(customerId: string, email: string): Promise<number> {
|
||||
return this.prisma.order
|
||||
.updateMany({
|
||||
where: { customerId: null, email: { equals: email, mode: 'insensitive' } },
|
||||
data: { customerId },
|
||||
})
|
||||
.then((result) => result.count);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Public surface of CustomersModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* AuthModule uses `provision()` during registration; CheckoutModule reads the
|
||||
* default shipping address to prefill. Everything else stays private.
|
||||
*/
|
||||
export {};
|
||||
export { CustomersService } from '../customers.service';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
TOKEN_AUDIENCES,
|
||||
type AuthenticatedActor,
|
||||
type OffsetPaginated,
|
||||
type Order,
|
||||
type OrderListItem,
|
||||
} from '@sport/types';
|
||||
import { orderListQuerySchema, type OrderListQuery } from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import { RequireAudience } from '@/common/decorators/require-permissions.decorator';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
import { CustomersService } from '@/modules/customers/public';
|
||||
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
/**
|
||||
* A shopper's own order history.
|
||||
*
|
||||
* Separate from `OrdersAdminController` because the authorisation model is
|
||||
* different in kind, not in degree: the admin surface is permission-gated and
|
||||
* can read any order; this one is session-scoped and can only ever read the
|
||||
* caller's. Putting both on one controller would mean one forgotten guard is a
|
||||
* full order-history leak.
|
||||
*/
|
||||
@ApiTags('account/orders')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.STOREFRONT)
|
||||
@Controller('account/orders')
|
||||
export class MyOrdersController {
|
||||
constructor(
|
||||
private readonly orders: OrdersService,
|
||||
private readonly customers: CustomersService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Your orders, newest first' })
|
||||
async list(
|
||||
@Query(new ZodValidationPipe(orderListQuerySchema)) query: OrderListQuery,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<OffsetPaginated<OrderListItem>> {
|
||||
return this.orders.listForCustomer(await this.requireCustomerId(actor), query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'One of your orders' })
|
||||
async getById(
|
||||
@Param('id') id: string,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<Order> {
|
||||
return this.orders.getForCustomer(await this.requireCustomerId(actor), id);
|
||||
}
|
||||
|
||||
private async requireCustomerId(actor: AuthenticatedActor): Promise<string> {
|
||||
const customerId = await this.customers.resolveCustomerId(actor.userId);
|
||||
if (!customerId) throw AppException.notFound('Customer profile');
|
||||
|
||||
return customerId;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CustomersModule } from '@/modules/customers/customers.module';
|
||||
import { PromotionsModule } from '@/modules/promotions/promotions.module';
|
||||
|
||||
import { MyOrdersController } from './my-orders.controller';
|
||||
import { OrdersAdminController } from './orders.controller';
|
||||
import { OrdersMapper } from './orders.mapper';
|
||||
import { OrdersRepository } from './orders.repository';
|
||||
@@ -18,8 +20,8 @@ import { OrdersService } from './orders.service';
|
||||
* EXTRACTION CANDIDATE.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PromotionsModule],
|
||||
controllers: [OrdersAdminController],
|
||||
imports: [PromotionsModule, CustomersModule],
|
||||
controllers: [OrdersAdminController, MyOrdersController],
|
||||
providers: [OrdersService, OrdersRepository, OrdersMapper],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
|
||||
@@ -18,6 +18,9 @@ const lineSelect = {
|
||||
const detailSelect = {
|
||||
id: true,
|
||||
number: true,
|
||||
// Needed to prove ownership on the account routes; never mapped into the
|
||||
// response, which is why it does not appear on the Order type.
|
||||
customerId: true,
|
||||
status: true,
|
||||
paymentStatus: true,
|
||||
fulfillmentStatus: true,
|
||||
|
||||
@@ -66,6 +66,46 @@ export class OrdersService {
|
||||
return this.mapper.toOrder(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* One customer's own orders.
|
||||
*
|
||||
* `customerId` comes from the session, never from the request, so there is no
|
||||
* parameter an attacker could point at somebody else's history. Guest orders
|
||||
* placed before the account existed appear here too — registration adopts
|
||||
* them by email (see CustomersService.provision).
|
||||
*/
|
||||
async listForCustomer(
|
||||
customerId: string,
|
||||
query: OrderListQuery,
|
||||
): Promise<OffsetPaginated<OrderListItem>> {
|
||||
const [rows, totalItems] = await this.repository.list(
|
||||
{ customerId },
|
||||
(query.page - 1) * query.perPage,
|
||||
query.perPage,
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.mapper.toListItem(row)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One of the customer's own orders. Scoped, so a stray id 404s. */
|
||||
async getForCustomer(customerId: string, orderId: string): Promise<Order> {
|
||||
const row = await this.repository.findById(orderId);
|
||||
if (!row || row.customerId !== customerId) throw AppException.notFound('Order');
|
||||
|
||||
return this.mapper.toOrder(row);
|
||||
}
|
||||
|
||||
async list(query: OrderListQuery): Promise<OffsetPaginated<OrderListItem>> {
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
|
||||
@@ -131,6 +131,56 @@ export class UsersService {
|
||||
* granted access that was never granted — the worst possible failure mode for
|
||||
* a permissions screen.
|
||||
*/
|
||||
/**
|
||||
* Creates a shopper account.
|
||||
*
|
||||
* Separate from `create()` rather than widening its input type: `create()`
|
||||
* backs the admin's "add a user" screen, whose schema only allows STAFF,
|
||||
* ADMIN and SUPER_ADMIN. Letting CUSTOMER through there would make it
|
||||
* possible to mint shopper accounts from the back office by accident, and
|
||||
* would put self-registration and staff provisioning on the same code path.
|
||||
*/
|
||||
async createCustomer(
|
||||
input: {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string | null;
|
||||
roleIds: readonly string[];
|
||||
},
|
||||
passwordHash: string,
|
||||
): Promise<{ id: string }> {
|
||||
const existing = await this.repository.findForAuthByEmail(input.email);
|
||||
if (existing) {
|
||||
throw AppException.conflict('An account with that email already exists.');
|
||||
}
|
||||
|
||||
const user = await this.repository.create({
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
type: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone ?? null,
|
||||
roles: { createMany: { data: input.roleIds.map((roleId) => ({ roleId })) } },
|
||||
});
|
||||
|
||||
return { id: user.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a seeded role by its key.
|
||||
*
|
||||
* Registration needs the CUSTOMER role id and must not hard-code a uuid —
|
||||
* roles are data (rows a SUPER_ADMIN can edit), so the key is the stable
|
||||
* reference and the id is not.
|
||||
*/
|
||||
async findRoleIdByKey(key: string): Promise<string | null> {
|
||||
const role = await this.rolesRepository.findByKey(key);
|
||||
return role?.id ?? null;
|
||||
}
|
||||
|
||||
private async assertRolesExist(roleIds: readonly string[]): Promise<void> {
|
||||
if (roleIds.length === 0) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user