feat/catalog-inventory #2

Merged
mihir merged 7 commits from feat/catalog-inventory into main 2026-09-10 23:43:44 +05:30
14 changed files with 888 additions and 0 deletions
Showing only changes of commit 14900fe83e - Show all commits

View File

@ -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 } } } : {}),
};
}

View File

@ -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');
}

View File

@ -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 {}

View File

@ -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>;

View File

@ -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;
});
}
}

View File

@ -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);
}
}

View File

@ -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;
});
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

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

View File

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

View File

@ -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;
});
}
}

192
test/catalog.spec.ts Normal file
View File

@ -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);
});
});

113
test/helpers/commerce.ts Normal file
View File

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