feat/catalog-inventory #2
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
|
import type { CatalogQuery } from './catalog.schemas';
|
||||||
|
|
||||||
|
export function catalogWhere(
|
||||||
|
organizationId: string,
|
||||||
|
query: CatalogQuery,
|
||||||
|
publishedOnly = false,
|
||||||
|
): Prisma.ProductWhereInput {
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
...(publishedOnly ? { status: 'PUBLISHED' } : {}),
|
||||||
|
...(query.search
|
||||||
|
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||||
|
: {}),
|
||||||
|
...(query.groupId ? { groups: { some: { groupId: query.groupId } } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
|
||||||
|
export async function scopedProduct(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
const product = await tx.product.findFirst({ where: { id, organizationId } });
|
||||||
|
if (!product) throw new AppError('PRODUCT_NOT_FOUND');
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
export async function editableProduct(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
const product = await scopedProduct(tx, organizationId, id);
|
||||||
|
if (product.status === 'ARCHIVED') throw new AppError('PRODUCT_ARCHIVED');
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
export async function validateGroups(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
organizationId: string,
|
||||||
|
groupIds: string[],
|
||||||
|
) {
|
||||||
|
const count = await tx.catalogGroup.count({
|
||||||
|
where: { organizationId, id: { in: groupIds } },
|
||||||
|
});
|
||||||
|
if (count !== groupIds.length) throw new AppError('GROUP_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseModule } from '../database/database.module';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
import { ProductStore } from './product.store';
|
||||||
|
import { VariantStore } from './variant.store';
|
||||||
|
import { GroupStore } from './group.store';
|
||||||
|
import { StorefrontStore } from './storefront.store';
|
||||||
|
import { ProductsController } from './products.controller';
|
||||||
|
import { GroupsController } from './groups.controller';
|
||||||
|
import { StorefrontController } from './storefront.controller';
|
||||||
|
import { StorefrontGuard } from './storefront.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, IdentityModule],
|
||||||
|
providers: [
|
||||||
|
ProductStore,
|
||||||
|
VariantStore,
|
||||||
|
GroupStore,
|
||||||
|
StorefrontStore,
|
||||||
|
StorefrontGuard,
|
||||||
|
],
|
||||||
|
controllers: [ProductsController, GroupsController, StorefrontController],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { text, ids } from '../common/input';
|
||||||
|
import { pageSchema } from '../identity/identity.schemas';
|
||||||
|
|
||||||
|
const slug = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.max(max)
|
||||||
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||||
|
export const productSchema = z
|
||||||
|
.object({
|
||||||
|
name: text(160),
|
||||||
|
slug: slug(160),
|
||||||
|
description: text(5000, 0).default(''),
|
||||||
|
groupIds: ids.default([]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const productStatusSchema = z
|
||||||
|
.object({ status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']) })
|
||||||
|
.strict();
|
||||||
|
export const groupSchema = z
|
||||||
|
.object({
|
||||||
|
name: text(100),
|
||||||
|
slug: slug(100),
|
||||||
|
kind: z.enum(['CATEGORY', 'COLLECTION']),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const variantSchema = z
|
||||||
|
.object({
|
||||||
|
sku: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[A-Z0-9][A-Z0-9_-]*$/),
|
||||||
|
name: text(160),
|
||||||
|
price: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(0|[1-9]\d{0,9})\.\d{2}$/)
|
||||||
|
.refine((value) => value !== '0.00'),
|
||||||
|
currency: z.enum(['INR', 'USD', 'EUR', 'GBP']).default('INR'),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
attributes: z
|
||||||
|
.record(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z][a-z0-9_]{0,31}$/)
|
||||||
|
.refine((key) => !['constructor', 'prototype'].includes(key)),
|
||||||
|
text(100),
|
||||||
|
)
|
||||||
|
.refine((value) => Object.keys(value).length <= 20)
|
||||||
|
.default({}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const catalogQuery = pageSchema
|
||||||
|
.extend({
|
||||||
|
search: text(100).optional(),
|
||||||
|
groupId: z.uuid().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type ProductInput = z.infer<typeof productSchema>;
|
||||||
|
export type VariantInput = z.infer<typeof variantSchema>;
|
||||||
|
export type GroupInput = z.infer<typeof groupSchema>;
|
||||||
|
export type CatalogQuery = z.infer<typeof catalogQuery>;
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
import { AccessStore } from '../identity/access.store';
|
||||||
|
import { recordAudit } from '../identity/audit';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import type { PageInput } from '../identity/identity.schemas';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import type { GroupInput } from './catalog.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GroupStore {
|
||||||
|
constructor(
|
||||||
|
private readonly db: DatabaseService,
|
||||||
|
private readonly access: AccessStore,
|
||||||
|
) {}
|
||||||
|
list(actor: Principal, page: PageInput) {
|
||||||
|
return this.db.catalogGroup.findMany({
|
||||||
|
where: { organizationId: actor.organizationId },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
take: page.limit,
|
||||||
|
skip: page.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
save(actor: Principal, input: GroupInput, id?: string) {
|
||||||
|
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
|
||||||
|
if (
|
||||||
|
id &&
|
||||||
|
!(await tx.catalogGroup.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
throw new AppError('GROUP_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const group = id
|
||||||
|
? await tx.catalogGroup.update({ where: { id }, data: input })
|
||||||
|
: await tx.catalogGroup.create({
|
||||||
|
data: { ...input, organizationId: actor.organizationId },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
id ? 'catalog_group.updated' : 'catalog_group.created',
|
||||||
|
group.id,
|
||||||
|
);
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SchemaPipe } from '../common/validation.pipe';
|
||||||
|
import {
|
||||||
|
CurrentPrincipal,
|
||||||
|
RequirePermission,
|
||||||
|
} from '../identity/access.decorator';
|
||||||
|
import { pageSchema, type PageInput } from '../identity/identity.schemas';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { GroupStore } from './group.store';
|
||||||
|
import { groupSchema, type GroupInput } from './catalog.schemas';
|
||||||
|
|
||||||
|
@Controller('catalog-groups')
|
||||||
|
export class GroupsController {
|
||||||
|
constructor(private readonly groups: GroupStore) {}
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('catalog.read')
|
||||||
|
list(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Query(new SchemaPipe(pageSchema)) page: PageInput,
|
||||||
|
) {
|
||||||
|
return this.groups.list(actor, page);
|
||||||
|
}
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('catalog.manage')
|
||||||
|
create(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Body(new SchemaPipe(groupSchema)) input: GroupInput,
|
||||||
|
) {
|
||||||
|
return this.groups.save(actor, input);
|
||||||
|
}
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermission('catalog.manage')
|
||||||
|
update(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(groupSchema)) input: GroupInput,
|
||||||
|
) {
|
||||||
|
return this.groups.save(actor, input, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { catalogWhere } from './catalog-query';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
import { AccessStore } from '../identity/access.store';
|
||||||
|
import { recordAudit } from '../identity/audit';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import {
|
||||||
|
editableProduct,
|
||||||
|
scopedProduct,
|
||||||
|
validateGroups,
|
||||||
|
} from './catalog.helpers';
|
||||||
|
import type { CatalogQuery, ProductInput } from './catalog.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductStore {
|
||||||
|
constructor(
|
||||||
|
private readonly db: DatabaseService,
|
||||||
|
private readonly access: AccessStore,
|
||||||
|
) {}
|
||||||
|
list(actor: Principal, query: CatalogQuery) {
|
||||||
|
return this.db.product.findMany({
|
||||||
|
where: catalogWhere(actor.organizationId, query),
|
||||||
|
select: { id: true, name: true, slug: true, status: true },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit,
|
||||||
|
skip: query.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async get(actor: Principal, id: string) {
|
||||||
|
await scopedProduct(this.db, actor.organizationId, id);
|
||||||
|
return this.db.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
variants: { orderBy: { id: 'asc' } },
|
||||||
|
groups: { include: { group: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
save(actor: Principal, input: ProductInput, id?: string) {
|
||||||
|
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
|
||||||
|
if (id) await editableProduct(tx, actor.organizationId, id);
|
||||||
|
await validateGroups(tx, actor.organizationId, input.groupIds);
|
||||||
|
const { groupIds, ...data } = input;
|
||||||
|
const product = id
|
||||||
|
? await tx.product.update({ where: { id }, data })
|
||||||
|
: await tx.product.create({
|
||||||
|
data: { ...data, organizationId: actor.organizationId },
|
||||||
|
});
|
||||||
|
await tx.productGroup.deleteMany({ where: { productId: product.id } });
|
||||||
|
await tx.productGroup.createMany({
|
||||||
|
data: groupIds.map((groupId) => ({
|
||||||
|
productId: product.id,
|
||||||
|
groupId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
id ? 'product.updated' : 'product.created',
|
||||||
|
product.id,
|
||||||
|
);
|
||||||
|
return product;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
actor: Principal,
|
||||||
|
id: string,
|
||||||
|
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED',
|
||||||
|
) {
|
||||||
|
return this.access.mutate(actor, 'catalog.publish', async (tx) => {
|
||||||
|
await editableProduct(tx, actor.organizationId, id);
|
||||||
|
if (
|
||||||
|
status === 'PUBLISHED' &&
|
||||||
|
!(await tx.productVariant.count({
|
||||||
|
where: { productId: id, active: true },
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
throw new AppError('PRODUCT_NOT_PUBLISHABLE');
|
||||||
|
}
|
||||||
|
const product = await tx.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
`product.${status.toLowerCase()}`,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return product;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SchemaPipe } from '../common/validation.pipe';
|
||||||
|
import {
|
||||||
|
CurrentPrincipal,
|
||||||
|
RequirePermission,
|
||||||
|
} from '../identity/access.decorator';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { ProductStore } from './product.store';
|
||||||
|
import { VariantStore } from './variant.store';
|
||||||
|
import {
|
||||||
|
catalogQuery,
|
||||||
|
productSchema,
|
||||||
|
productStatusSchema,
|
||||||
|
variantSchema,
|
||||||
|
type CatalogQuery,
|
||||||
|
type ProductInput,
|
||||||
|
type VariantInput,
|
||||||
|
} from './catalog.schemas';
|
||||||
|
|
||||||
|
@Controller('products')
|
||||||
|
export class ProductsController {
|
||||||
|
constructor(
|
||||||
|
private readonly products: ProductStore,
|
||||||
|
private readonly variants: VariantStore,
|
||||||
|
) {}
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('catalog.read')
|
||||||
|
list(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Query(new SchemaPipe(catalogQuery)) query: CatalogQuery,
|
||||||
|
) {
|
||||||
|
return this.products.list(actor, query);
|
||||||
|
}
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('catalog.read')
|
||||||
|
get(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.products.get(actor, id);
|
||||||
|
}
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('catalog.manage')
|
||||||
|
create(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Body(new SchemaPipe(productSchema)) input: ProductInput,
|
||||||
|
) {
|
||||||
|
return this.products.save(actor, input);
|
||||||
|
}
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermission('catalog.manage')
|
||||||
|
update(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(productSchema)) input: ProductInput,
|
||||||
|
) {
|
||||||
|
return this.products.save(actor, input, id);
|
||||||
|
}
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePermission('catalog.publish')
|
||||||
|
status(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(productStatusSchema))
|
||||||
|
input: { status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' },
|
||||||
|
) {
|
||||||
|
return this.products.setStatus(actor, id, input.status);
|
||||||
|
}
|
||||||
|
@Post(':id/variants')
|
||||||
|
@RequirePermission('catalog.manage')
|
||||||
|
createVariant(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new SchemaPipe(variantSchema)) input: VariantInput,
|
||||||
|
) {
|
||||||
|
return this.variants.save(actor, id, input);
|
||||||
|
}
|
||||||
|
@Put(':id/variants/:variantId')
|
||||||
|
@RequirePermission('catalog.manage')
|
||||||
|
updateVariant(
|
||||||
|
@CurrentPrincipal() actor: Principal,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('variantId', ParseUUIDPipe) variantId: string,
|
||||||
|
@Body(new SchemaPipe(variantSchema)) input: VariantInput,
|
||||||
|
) {
|
||||||
|
return this.variants.save(actor, id, input, variantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SchemaPipe } from '../common/validation.pipe';
|
||||||
|
import { Public } from '../identity/access.decorator';
|
||||||
|
import { StorefrontStore } from './storefront.store';
|
||||||
|
import { StorefrontGuard } from './storefront.guard';
|
||||||
|
import { catalogQuery, type CatalogQuery } from './catalog.schemas';
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@UseGuards(StorefrontGuard)
|
||||||
|
@Controller('storefront/:organizationId/products')
|
||||||
|
export class StorefrontController {
|
||||||
|
constructor(private readonly storefront: StorefrontStore) {}
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Param('organizationId', ParseUUIDPipe) organizationId: string,
|
||||||
|
@Query(new SchemaPipe(catalogQuery)) query: CatalogQuery,
|
||||||
|
) {
|
||||||
|
return this.storefront.list(organizationId, query);
|
||||||
|
}
|
||||||
|
@Get(':id')
|
||||||
|
get(
|
||||||
|
@Param('organizationId', ParseUUIDPipe) organizationId: string,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.storefront.get(organizationId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { RateLimitService } from '../identity/rate-limit.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorefrontGuard implements CanActivate {
|
||||||
|
constructor(private readonly limits: RateLimitService) {}
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
await this.limits.consume(`storefront:${request.ip}`, 120, 60);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { catalogWhere } from './catalog-query';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import type { CatalogQuery } from './catalog.schemas';
|
||||||
|
|
||||||
|
const storefrontView = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
variants: {
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { id: 'asc' as const },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sku: true,
|
||||||
|
name: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
|
attributes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
select: {
|
||||||
|
group: { select: { id: true, name: true, slug: true, kind: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
@Injectable()
|
||||||
|
export class StorefrontStore {
|
||||||
|
constructor(private readonly db: DatabaseService) {}
|
||||||
|
list(organizationId: string, query: CatalogQuery) {
|
||||||
|
return this.db.product.findMany({
|
||||||
|
where: catalogWhere(organizationId, query, true),
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit,
|
||||||
|
skip: query.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async get(organizationId: string, id: string) {
|
||||||
|
const product = await this.db.product.findFirst({
|
||||||
|
where: { id, organizationId, status: 'PUBLISHED' },
|
||||||
|
select: storefrontView,
|
||||||
|
});
|
||||||
|
if (!product) throw new AppError('PRODUCT_NOT_FOUND');
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccessStore } from '../identity/access.store';
|
||||||
|
import { recordAudit } from '../identity/audit';
|
||||||
|
import type { Principal } from '../identity/identity.types';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { editableProduct } from './catalog.helpers';
|
||||||
|
import type { VariantInput } from './catalog.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VariantStore {
|
||||||
|
constructor(private readonly access: AccessStore) {}
|
||||||
|
save(actor: Principal, productId: string, input: VariantInput, id?: string) {
|
||||||
|
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
|
||||||
|
const product = await editableProduct(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
productId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
id &&
|
||||||
|
!(await tx.productVariant.findFirst({
|
||||||
|
where: { id, productId, organizationId: actor.organizationId },
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
throw new AppError('VARIANT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!id &&
|
||||||
|
(await tx.productVariant.count({ where: { productId } })) >= 100
|
||||||
|
)
|
||||||
|
throw new AppError('VARIANT_LIMIT');
|
||||||
|
if (
|
||||||
|
id &&
|
||||||
|
!input.active &&
|
||||||
|
product.status === 'PUBLISHED' &&
|
||||||
|
!(await tx.productVariant.count({
|
||||||
|
where: { productId, active: true, id: { not: id } },
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
throw new AppError('VARIANT_REQUIRED');
|
||||||
|
}
|
||||||
|
const variant = id
|
||||||
|
? await tx.productVariant.update({ where: { id }, data: input })
|
||||||
|
: await tx.productVariant.create({
|
||||||
|
data: { ...input, productId, organizationId: actor.organizationId },
|
||||||
|
});
|
||||||
|
await recordAudit(
|
||||||
|
tx,
|
||||||
|
actor.organizationId,
|
||||||
|
actor.userId,
|
||||||
|
id ? 'variant.updated' : 'variant.created',
|
||||||
|
variant.id,
|
||||||
|
);
|
||||||
|
return variant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { secondActor, seedProduct, variantInput } from './helpers/commerce';
|
||||||
|
|
||||||
|
describe('catalogue API', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx?.close();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ctx.clearLimits();
|
||||||
|
});
|
||||||
|
const auth = { type: 'bearer' as const };
|
||||||
|
it('keeps drafts private and publishes only sellable products', async () => {
|
||||||
|
const { product, variant } = await seedProduct(ctx);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get(
|
||||||
|
`/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`,
|
||||||
|
)
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.patch(`/api/v1/products/${product.id}/status`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ status: 'PUBLISHED' })
|
||||||
|
.expect(200);
|
||||||
|
const response = await ctx
|
||||||
|
.api()
|
||||||
|
.get(
|
||||||
|
`/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`,
|
||||||
|
)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body.variants[0].price).toBe('499');
|
||||||
|
expect(response.body.variants[0].id).toBe(variant.id);
|
||||||
|
expect(response.body).not.toHaveProperty('organizationId');
|
||||||
|
expect(response.body).not.toHaveProperty('status');
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get(`/api/v1/storefront/${randomUUID()}/products/${product.id}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('rejects publishing without a variant and deactivating the final published variant', async () => {
|
||||||
|
const empty = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: 'Empty', slug: randomUUID() })
|
||||||
|
.expect(201);
|
||||||
|
const denied = await ctx
|
||||||
|
.api()
|
||||||
|
.patch(`/api/v1/products/${empty.body.id}/status`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ status: 'PUBLISHED' })
|
||||||
|
.expect(409);
|
||||||
|
expect(denied.body.code).toBe('PRODUCT_NOT_PUBLISHABLE');
|
||||||
|
const { product, variant } = await seedProduct(ctx, true);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/products/${product.id}/variants/${variant.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...variantInput, sku: variant.sku, active: false })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
it('updates products, variants and groups using explicit organization scope', async () => {
|
||||||
|
const { product, variant } = await seedProduct(ctx);
|
||||||
|
const group = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/catalog-groups')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: 'Festive', slug: randomUUID(), kind: 'COLLECTION' })
|
||||||
|
.expect(201);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/products/${product.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: 'Updated', slug: product.slug, groupIds: [group.body.id] })
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/products/${product.id}/variants/${variant.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...variantInput, sku: variant.sku, price: '599.50' })
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/catalog-groups/${group.body.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: 'Diwali', slug: group.body.slug, kind: 'COLLECTION' })
|
||||||
|
.expect(200);
|
||||||
|
const filtered = await ctx
|
||||||
|
.api()
|
||||||
|
.get(`/api/v1/products?search=Updated&groupId=${group.body.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(filtered.body.map((row: { id: string }) => row.id)).toContain(
|
||||||
|
product.id,
|
||||||
|
);
|
||||||
|
const detail = await ctx
|
||||||
|
.api()
|
||||||
|
.get(`/api/v1/products/${product.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
expect(detail.body.groups[0].group.name).toBe('Diwali');
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/catalog-groups?limit=1')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
it('denies unprivileged and cross-organization mutations', async () => {
|
||||||
|
const { product } = await seedProduct(ctx);
|
||||||
|
const outsider = await secondActor(ctx, false, [
|
||||||
|
'catalog.manage',
|
||||||
|
'catalog.read',
|
||||||
|
]);
|
||||||
|
const noRole = await secondActor(ctx);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/products')
|
||||||
|
.auth(noRole.token, auth)
|
||||||
|
.expect(403);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/products/${product.id}`)
|
||||||
|
.auth(outsider.token, auth)
|
||||||
|
.send({ name: 'Hijacked', slug: product.slug })
|
||||||
|
.expect(404);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get(`/api/v1/products/${product.id}`)
|
||||||
|
.auth(outsider.token, auth)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('validates money, text, mass assignment and duplicates', async () => {
|
||||||
|
const { product, variant } = await seedProduct(ctx);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post(`/api/v1/products/${product.id}/variants`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...variantInput, price: 0.1 })
|
||||||
|
.expect(400);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post(`/api/v1/products/${product.id}/variants`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ ...variantInput, sku: variant.sku })
|
||||||
|
.expect(409);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: '<script>alert(1)</script>', slug: 'unsafe' })
|
||||||
|
.expect(400);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: 'Unsafe', slug: 'unsafe', organizationId: randomUUID() })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('archives products and omits drafts from public listing', async () => {
|
||||||
|
const { product } = await seedProduct(ctx, true);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get(
|
||||||
|
`/api/v1/storefront/${ctx.owner.organizationId}/products?search=Rose&limit=5`,
|
||||||
|
)
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.patch(`/api/v1/products/${product.id}/status`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ status: 'ARCHIVED' })
|
||||||
|
.expect(200);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.put(`/api/v1/products/${product.id}`)
|
||||||
|
.auth(ctx.token, auth)
|
||||||
|
.send({ name: 'Again', slug: product.slug })
|
||||||
|
.expect(409);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get(
|
||||||
|
`/api/v1/storefront/${ctx.owner.organizationId}/products/${product.id}`,
|
||||||
|
)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { IdentityApp } from './identity-app';
|
||||||
|
import { issueToken } from '../../src/identity/tokens';
|
||||||
|
|
||||||
|
export const variantInput = {
|
||||||
|
sku: 'CANDLE',
|
||||||
|
name: 'Rose 200g',
|
||||||
|
price: '499.00',
|
||||||
|
currency: 'INR' as const,
|
||||||
|
active: true,
|
||||||
|
attributes: { fragrance: 'rose' },
|
||||||
|
};
|
||||||
|
export const addressInput = {
|
||||||
|
recipient: 'Test Customer',
|
||||||
|
line1: '12 Test Road',
|
||||||
|
line2: '',
|
||||||
|
city: 'Pune',
|
||||||
|
region: 'Maharashtra',
|
||||||
|
postalCode: '411001',
|
||||||
|
countryCode: 'IN',
|
||||||
|
phone: '+919876543210',
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
export async function seedProduct(ctx: IdentityApp, publish = false) {
|
||||||
|
const slug = 'candle-' + randomUUID();
|
||||||
|
const product = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ name: 'Rose Candle', slug, description: 'Handmade candle' })
|
||||||
|
.expect(201);
|
||||||
|
const variant = await ctx
|
||||||
|
.api()
|
||||||
|
.post(`/api/v1/products/${product.body.id}/variants`)
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ ...variantInput, sku: randomUUID() })
|
||||||
|
.expect(201);
|
||||||
|
if (publish)
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.patch(`/api/v1/products/${product.body.id}/status`)
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ status: 'PUBLISHED' })
|
||||||
|
.expect(200);
|
||||||
|
return { product: product.body, variant: variant.body };
|
||||||
|
}
|
||||||
|
export async function seedStock(ctx: IdentityApp, onHand = 10) {
|
||||||
|
const catalog = await seedProduct(ctx, true);
|
||||||
|
const warehouse = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/inventory/warehouses')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ name: randomUUID() })
|
||||||
|
.expect(201);
|
||||||
|
const stock = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/inventory/stock-items')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ warehouseId: warehouse.body.id, variantId: catalog.variant.id })
|
||||||
|
.expect(201);
|
||||||
|
if (onHand)
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/inventory/adjustments')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({
|
||||||
|
stockItemId: stock.body.id,
|
||||||
|
delta: onHand,
|
||||||
|
reason: 'Opening balance',
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
return { ...catalog, warehouse: warehouse.body, stock: stock.body };
|
||||||
|
}
|
||||||
|
export async function secondActor(
|
||||||
|
ctx: IdentityApp,
|
||||||
|
sameOrganization = true,
|
||||||
|
permissions: string[] = [],
|
||||||
|
) {
|
||||||
|
const organizationId = sameOrganization
|
||||||
|
? ctx.owner.organizationId
|
||||||
|
: (
|
||||||
|
await ctx.db.organization.create({
|
||||||
|
data: { name: 'Other organization' },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
const user = await ctx.db.user.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
email: randomUUID() + '@example.com',
|
||||||
|
name: 'Test account',
|
||||||
|
passwordHash: 'not-a-login-credential',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (permissions.length) {
|
||||||
|
const role = await ctx.db.role.create({
|
||||||
|
data: { organizationId, name: randomUUID(), permissions },
|
||||||
|
});
|
||||||
|
await ctx.db.userRole.create({
|
||||||
|
data: { organizationId, userId: user.id, roleId: role.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const token = issueToken();
|
||||||
|
await ctx.db.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash: token.tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 60000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { token: token.token, userId: user.id, organizationId };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue