From 3717cdba5983fe1c5d43ee7863828f0c0da9864f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 22:08:34 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20admin=20de=20cat=C3=A1logo=20de?= =?UTF-8?q?=20insumos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/drizzle/0039_categories_admin.sql | 10 + .../domain/authorization/permission.spec.ts | 2 + .../domain/authorization/permission.ts | 1 + .../application/category-admin.errors.ts | 34 +++ .../supplies/application/create-category.ts | 58 ++++ .../application/list-categories.spec.ts | 8 + .../supplies/application/list-categories.ts | 6 +- .../supplies/application/update-category.ts | 88 ++++++ .../supplies/domain/category-definition.ts | 7 + .../domain/ports/category.repository.ts | 28 +- .../drizzle/drizzle-category.repository.ts | 276 +++++++++++++++++- .../supplies/infrastructure/drizzle/schema.ts | 1 + .../infrastructure/http/admin-category.dto.ts | 165 +++++++++++ .../http/categories-admin.controller.spec.ts | 83 ++++++ .../http/categories-admin.controller.ts | 237 +++++++++++++++ .../http/categories.controller.spec.ts | 38 +++ .../http/categories.controller.ts | 39 ++- .../http/category-response.dto.ts | 3 + .../supplies/infrastructure/http/locale.ts | 37 +++ .../src/contexts/supplies/supplies.module.ts | 25 +- 20 files changed, 1123 insertions(+), 23 deletions(-) create mode 100644 apps/api/drizzle/0039_categories_admin.sql create mode 100644 apps/api/src/contexts/supplies/application/category-admin.errors.ts create mode 100644 apps/api/src/contexts/supplies/application/create-category.ts create mode 100644 apps/api/src/contexts/supplies/application/update-category.ts create mode 100644 apps/api/src/contexts/supplies/infrastructure/http/admin-category.dto.ts create mode 100644 apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts create mode 100644 apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.ts create mode 100644 apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts create mode 100644 apps/api/src/contexts/supplies/infrastructure/http/locale.ts diff --git a/apps/api/drizzle/0039_categories_admin.sql b/apps/api/drizzle/0039_categories_admin.sql new file mode 100644 index 00000000..6afafee0 --- /dev/null +++ b/apps/api/drizzle/0039_categories_admin.sql @@ -0,0 +1,10 @@ +-- Category admin support (#221): allow soft-hiding categories without losing +-- the shared taxonomy, while keeping the existing public taxonomy seed intact. + +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS archived_at timestamp with time zone; +--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS categories_archived_at_idx + ON categories (archived_at); +--> statement-breakpoint diff --git a/apps/api/src/contexts/identity/domain/authorization/permission.spec.ts b/apps/api/src/contexts/identity/domain/authorization/permission.spec.ts index 441f23f9..3ad2910f 100644 --- a/apps/api/src/contexts/identity/domain/authorization/permission.spec.ts +++ b/apps/api/src/contexts/identity/domain/authorization/permission.spec.ts @@ -12,10 +12,12 @@ describe('permission catalog', () => { 'role:grant', 'group:manage_members', 'apikey:create', + 'catalogue:manage', ]; for (const p of known) { expect(ALL_PERMISSIONS).toContain(p); } + expect(isPermission('catalogue:manage')).toBe(true); }); it('includes the transport logistics permissions (EPIC #103)', () => { diff --git a/apps/api/src/contexts/identity/domain/authorization/permission.ts b/apps/api/src/contexts/identity/domain/authorization/permission.ts index 69ca6941..32362bc8 100644 --- a/apps/api/src/contexts/identity/domain/authorization/permission.ts +++ b/apps/api/src/contexts/identity/domain/authorization/permission.ts @@ -34,6 +34,7 @@ export const PERMISSION_CATALOG = { role: ['grant', 'revoke', 'create_custom'], apikey: ['create', 'revoke'], audit: ['read'], + catalogue: ['manage'], // Logística de transporte (EPIC #103). 'create'/'read' existían como data // antes del enforcement; #106 añade 'assign' (coordinador asigna capacidad y // transportista) y 'update' (cambios de estado por el coordinador). 'track' diff --git a/apps/api/src/contexts/supplies/application/category-admin.errors.ts b/apps/api/src/contexts/supplies/application/category-admin.errors.ts new file mode 100644 index 00000000..73e3d290 --- /dev/null +++ b/apps/api/src/contexts/supplies/application/category-admin.errors.ts @@ -0,0 +1,34 @@ +export class CategoryNotFoundError extends Error { + constructor(slug: string) { + super(`Category not found: ${slug}`); + this.name = 'CategoryNotFoundError'; + } +} + +export class CategoryAlreadyExistsError extends Error { + constructor(slug: string) { + super(`Category already exists: ${slug}`); + this.name = 'CategoryAlreadyExistsError'; + } +} + +export class CategoryParentNotFoundError extends Error { + constructor(slug: string) { + super(`Parent category not found: ${slug}`); + this.name = 'CategoryParentNotFoundError'; + } +} + +export class CategoryImmutableSlugError extends Error { + constructor(slug: string) { + super(`Core category slug cannot be changed: ${slug}`); + this.name = 'CategoryImmutableSlugError'; + } +} + +export class CategoryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'CategoryValidationError'; + } +} diff --git a/apps/api/src/contexts/supplies/application/create-category.ts b/apps/api/src/contexts/supplies/application/create-category.ts new file mode 100644 index 00000000..b5aaae9c --- /dev/null +++ b/apps/api/src/contexts/supplies/application/create-category.ts @@ -0,0 +1,58 @@ +import { + CategoryAlreadyExistsError, + CategoryParentNotFoundError, + CategoryValidationError, +} from './category-admin.errors'; +import { CategoryDefinition } from '../domain/category-definition'; +import { + CategoryRepository, + CategoryWriteInput, +} from '../domain/ports/category.repository'; + +export interface CreateCategoryCommand extends CategoryWriteInput {} + +export class CreateCategory { + constructor(private readonly repo: CategoryRepository) {} + + async execute(cmd: CreateCategoryCommand): Promise { + const slug = cmd.slug.trim(); + if (slug === '') { + throw new CategoryValidationError('Category slug is required'); + } + if (cmd.labelEs.trim() === '' || cmd.labelEn.trim() === '') { + throw new CategoryValidationError('Category labels are required'); + } + if (cmd.vertical.trim() === '') { + throw new CategoryValidationError('Category vertical is required'); + } + if ( + (await this.repo.findBySlug(slug, { includeArchived: true })) !== null + ) { + throw new CategoryAlreadyExistsError(slug); + } + if (cmd.parentSlug !== null) { + const parent = await this.repo.findBySlug(cmd.parentSlug, { + includeArchived: false, + }); + if (!parent) { + throw new CategoryParentNotFoundError(cmd.parentSlug); + } + } + + await this.repo.createCategory({ + ...cmd, + slug, + parentSlug: cmd.parentSlug, + archivedAt: cmd.archivedAt ?? null, + translations: cmd.translations ?? [], + }); + + const created = await this.repo.findBySlug(slug, { includeArchived: true }); + if (!created) { + throw new CategoryValidationError( + 'Category was created but cannot be reloaded', + ); + } + return created; + } +} diff --git a/apps/api/src/contexts/supplies/application/list-categories.spec.ts b/apps/api/src/contexts/supplies/application/list-categories.spec.ts index c3d48271..bd2f821b 100644 --- a/apps/api/src/contexts/supplies/application/list-categories.spec.ts +++ b/apps/api/src/contexts/supplies/application/list-categories.spec.ts @@ -13,11 +13,19 @@ describe('ListCategories', () => { parentSlug: null, vertical: 'general', sort: 10, + archivedAt: null, + translations: [ + { locale: 'es', label: 'Alimentos' }, + { locale: 'en', label: 'Food' }, + ], }, ]; const repo: CategoryRepository = { loadAliasMap: () => Promise.resolve(new Map()), listCategories: () => Promise.resolve(categories), + findBySlug: () => Promise.resolve(categories[0] ?? null), + createCategory: () => Promise.resolve(), + updateCategory: () => Promise.resolve(), }; const result = await new ListCategories(repo).execute(); diff --git a/apps/api/src/contexts/supplies/application/list-categories.ts b/apps/api/src/contexts/supplies/application/list-categories.ts index 605b894a..588989e8 100644 --- a/apps/api/src/contexts/supplies/application/list-categories.ts +++ b/apps/api/src/contexts/supplies/application/list-categories.ts @@ -9,7 +9,9 @@ import { CategoryDefinition } from '../domain/category-definition'; export class ListCategories { constructor(private readonly repo: CategoryRepository) {} - execute(): Promise { - return this.repo.listCategories(); + execute(options?: { + includeArchived?: boolean; + }): Promise { + return this.repo.listCategories(options); } } diff --git a/apps/api/src/contexts/supplies/application/update-category.ts b/apps/api/src/contexts/supplies/application/update-category.ts new file mode 100644 index 00000000..a4793373 --- /dev/null +++ b/apps/api/src/contexts/supplies/application/update-category.ts @@ -0,0 +1,88 @@ +import { + CategoryImmutableSlugError, + CategoryNotFoundError, + CategoryValidationError, +} from './category-admin.errors'; +import { Category } from '../domain/category'; +import { + CategoryRepository, + CategoryWriteInput, +} from '../domain/ports/category.repository'; +import { CategoryDefinition } from '../domain/category-definition'; + +export interface UpdateCategoryCommand { + slug?: string | undefined; + labelEs?: string | undefined; + labelEn?: string | undefined; + parentSlug?: string | null | undefined; + vertical?: string | undefined; + sort?: number | undefined; + archived?: boolean | undefined; + translations?: CategoryWriteInput['translations'] | undefined; +} + +export class UpdateCategory { + constructor(private readonly repo: CategoryRepository) {} + + async execute( + currentSlug: string, + cmd: UpdateCategoryCommand, + ): Promise { + const current = await this.repo.findBySlug(currentSlug, { + includeArchived: true, + }); + if (!current) { + throw new CategoryNotFoundError(currentSlug); + } + + const nextSlug = cmd.slug?.trim() ?? current.slug; + if (nextSlug === '') { + throw new CategoryValidationError('Category slug is required'); + } + const nextLabelEs = (cmd.labelEs ?? current.labelEs).trim(); + const nextLabelEn = (cmd.labelEn ?? current.labelEn).trim(); + const nextVertical = (cmd.vertical ?? current.vertical).trim(); + if (nextLabelEs === '' || nextLabelEn === '') { + throw new CategoryValidationError('Category labels are required'); + } + if (nextVertical === '') { + throw new CategoryValidationError('Category vertical is required'); + } + if (current.slug !== nextSlug) { + if (Object.values(Category).includes(current.slug as Category)) { + throw new CategoryImmutableSlugError(current.slug); + } + throw new CategoryValidationError('Category slug cannot be changed'); + } + if (cmd.parentSlug !== undefined && cmd.parentSlug === nextSlug) { + throw new CategoryValidationError('A category cannot be its own parent'); + } + + await this.repo.updateCategory(currentSlug, { + slug: nextSlug, + labelEs: nextLabelEs, + labelEn: nextLabelEn, + parentSlug: + cmd.parentSlug !== undefined ? cmd.parentSlug : current.parentSlug, + vertical: nextVertical, + sort: cmd.sort ?? current.sort, + archivedAt: + cmd.archived === undefined + ? current.archivedAt + : cmd.archived + ? new Date() + : null, + translations: cmd.translations ?? current.translations, + }); + + const updated = await this.repo.findBySlug(nextSlug, { + includeArchived: true, + }); + if (!updated) { + throw new CategoryValidationError( + 'Category was updated but cannot be reloaded', + ); + } + return updated; + } +} diff --git a/apps/api/src/contexts/supplies/domain/category-definition.ts b/apps/api/src/contexts/supplies/domain/category-definition.ts index 9aff444e..4a674289 100644 --- a/apps/api/src/contexts/supplies/domain/category-definition.ts +++ b/apps/api/src/contexts/supplies/domain/category-definition.ts @@ -4,6 +4,11 @@ * sort order. Backed by the `categories` table. The slug is a plain string so * the schema can carry both core categories and finer subcategories. */ +export interface CategoryTranslation { + locale: string; + label: string; +} + export interface CategoryDefinition { slug: string; labelEs: string; @@ -11,4 +16,6 @@ export interface CategoryDefinition { parentSlug: string | null; vertical: string; sort: number; + archivedAt: Date | null; + translations: readonly CategoryTranslation[]; } diff --git a/apps/api/src/contexts/supplies/domain/ports/category.repository.ts b/apps/api/src/contexts/supplies/domain/ports/category.repository.ts index 2c73ad59..756d49e4 100644 --- a/apps/api/src/contexts/supplies/domain/ports/category.repository.ts +++ b/apps/api/src/contexts/supplies/domain/ports/category.repository.ts @@ -2,7 +2,33 @@ import { CategoryDefinition } from '../category-definition'; export const CATEGORY_REPOSITORY = Symbol('CATEGORY_REPOSITORY'); +export interface CategoryListOptions { + includeArchived?: boolean; +} + +export interface CategoryTranslationInput { + locale: string; + label: string; +} + +export interface CategoryWriteInput { + slug: string; + labelEs: string; + labelEn: string; + parentSlug: string | null; + vertical: string; + sort: number; + archivedAt?: Date | null; + translations?: readonly CategoryTranslationInput[]; +} + export interface CategoryRepository { loadAliasMap(): Promise>; - listCategories(): Promise; + listCategories(options?: CategoryListOptions): Promise; + findBySlug( + slug: string, + options?: CategoryListOptions, + ): Promise; + createCategory(input: CategoryWriteInput): Promise; + updateCategory(slug: string, input: CategoryWriteInput): Promise; } diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts index 1ed42c94..761fdd72 100644 --- a/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts @@ -1,8 +1,26 @@ -import { asc } from 'drizzle-orm'; -import { Db } from '../../../../shared/db'; -import { categoriesTable, categoryAliasesTable } from './schema'; -import { CategoryRepository } from '../../domain/ports/category.repository'; +import { and, asc, eq, inArray, isNull } from 'drizzle-orm'; import { CategoryDefinition } from '../../domain/category-definition'; +import { + CategoryAlreadyExistsError, + CategoryNotFoundError, + CategoryParentNotFoundError, + CategoryValidationError, +} from '../../application/category-admin.errors'; +import { + CategoryListOptions, + CategoryRepository, + CategoryTranslationInput, + CategoryWriteInput, +} from '../../domain/ports/category.repository'; +import { Db } from '../../../../shared/db'; +import { + categoriesTable, + categoryAliasesTable, + categoryTranslationsTable, +} from './schema'; + +type CategoryRow = typeof categoriesTable.$inferSelect; +type CategoryTranslationRow = typeof categoryTranslationsTable.$inferSelect; export class DrizzleCategoryRepository implements CategoryRepository { constructor(private readonly db: Db) {} @@ -12,18 +30,246 @@ export class DrizzleCategoryRepository implements CategoryRepository { return new Map(rows.map((r) => [r.aliasNorm, r.categorySlug])); } - async listCategories(): Promise { - const rows = await this.db + async listCategories( + options: CategoryListOptions = {}, + ): Promise { + const rows = await this.selectCategoryRows( + options.includeArchived === true, + ); + return this.hydrateCategories(rows); + } + + async findBySlug( + slug: string, + options: CategoryListOptions = {}, + ): Promise { + const rows = await this.selectCategoryRows( + options.includeArchived === true, + slug, + ); + return this.hydrateCategories(rows)[0] ?? null; + } + + async createCategory(input: CategoryWriteInput): Promise { + const slug = input.slug.trim(); + if (slug === '') { + throw new CategoryValidationError('Category slug is required'); + } + if (await this.findBySlug(slug, { includeArchived: true })) { + throw new CategoryAlreadyExistsError(slug); + } + await this.ensureValidParent(input.parentSlug, slug); + + await this.db.transaction(async (tx) => { + await tx.insert(categoriesTable).values({ + slug, + labelEs: input.labelEs, + labelEn: input.labelEn, + parentSlug: input.parentSlug, + vertical: input.vertical, + sort: input.sort, + archivedAt: input.archivedAt ?? null, + }); + + const translations = this.buildTranslationRows( + slug, + input.labelEs, + input.labelEn, + input.translations, + ); + if (translations.length > 0) { + await tx.insert(categoryTranslationsTable).values(translations); + } + }); + } + + async updateCategory(slug: string, input: CategoryWriteInput): Promise { + const current = await this.findBySlug(slug, { includeArchived: true }); + if (!current) { + throw new CategoryNotFoundError(slug); + } + + const nextSlug = input.slug.trim(); + if (nextSlug === '') { + throw new CategoryValidationError('Category slug is required'); + } + if (current.slug !== nextSlug) { + throw new CategoryValidationError('Category slug cannot be changed'); + } + + await this.ensureValidParent(input.parentSlug, nextSlug); + + await this.db.transaction(async (tx) => { + await tx + .update(categoriesTable) + .set({ + slug: nextSlug, + labelEs: input.labelEs, + labelEn: input.labelEn, + parentSlug: input.parentSlug, + vertical: input.vertical, + sort: input.sort, + archivedAt: input.archivedAt ?? current.archivedAt, + }) + .where(eq(categoriesTable.slug, current.slug)); + + await tx + .delete(categoryTranslationsTable) + .where(eq(categoryTranslationsTable.categorySlug, nextSlug)); + + const translations = this.buildTranslationRows( + nextSlug, + input.labelEs, + input.labelEn, + input.translations, + ); + if (translations.length > 0) { + await tx.insert(categoryTranslationsTable).values(translations); + } + }); + } + + private async ensureValidParent( + parentSlug: string | null, + currentSlug: string, + ): Promise { + if (parentSlug === null) { + return; + } + if (parentSlug === currentSlug) { + throw new CategoryValidationError('A category cannot be its own parent'); + } + const parent = await this.findBySlug(parentSlug, { + includeArchived: false, + }); + if (!parent) { + throw new CategoryParentNotFoundError(parentSlug); + } + } + + private buildTranslationRows( + slug: string, + labelEs: string, + labelEn: string, + translations?: readonly CategoryTranslationInput[], + ): CategoryTranslationRow[] { + const entries = new Map(); + entries.set('es', labelEs.trim()); + entries.set('en', labelEn.trim()); + for (const translation of translations ?? []) { + const locale = translation.locale.trim().toLowerCase(); + const label = translation.label.trim(); + if (!locale || !label) { + continue; + } + entries.set(locale, label); + } + return [...entries.entries()].map(([locale, label]) => ({ + categorySlug: slug, + locale, + label, + })); + } + + private async selectCategoryRows( + includeArchived: boolean, + slug?: string, + ): Promise< + Array<{ + category: CategoryRow; + translation: CategoryTranslationRow | null; + }> + > { + const filters = [ + slug !== undefined ? eq(categoriesTable.slug, slug) : undefined, + ]; + if (!includeArchived) { + filters.push(isNull(categoriesTable.archivedAt)); + } + + const where = filters.filter( + (f): f is NonNullable => f !== undefined, + ); + const categoryRows = + where.length > 0 + ? await this.db + .select() + .from(categoriesTable) + .where(and(...where)) + .orderBy(asc(categoriesTable.sort), asc(categoriesTable.slug)) + : await this.db + .select() + .from(categoriesTable) + .orderBy(asc(categoriesTable.sort), asc(categoriesTable.slug)); + + if (categoryRows.length === 0) { + return []; + } + + const translations = await this.db .select() - .from(categoriesTable) - .orderBy(asc(categoriesTable.sort)); - return rows.map((r) => ({ - slug: r.slug, - labelEs: r.labelEs, - labelEn: r.labelEn, - parentSlug: r.parentSlug ?? null, - vertical: r.vertical, - sort: r.sort, + .from(categoryTranslationsTable) + .where( + inArray( + categoryTranslationsTable.categorySlug, + categoryRows.map((row) => row.slug), + ), + ); + + const translationMap = new Map(); + for (const translation of translations) { + const bucket = translationMap.get(translation.categorySlug) ?? []; + bucket.push(translation); + translationMap.set(translation.categorySlug, bucket); + } + + return categoryRows.flatMap((category) => + (translationMap.get(category.slug) ?? [null]).map((translation) => ({ + category, + translation, + })), + ); + } + + private hydrateCategories( + rows: Array<{ + category: CategoryRow; + translation: CategoryTranslationRow | null; + }>, + ): CategoryDefinition[] { + const grouped = new Map(); + + for (const row of rows) { + const current = + grouped.get(row.category.slug) ?? + ({ + slug: row.category.slug, + labelEs: row.category.labelEs, + labelEn: row.category.labelEn, + parentSlug: row.category.parentSlug ?? null, + vertical: row.category.vertical, + sort: row.category.sort, + archivedAt: row.category.archivedAt ?? null, + translations: [], + } as CategoryDefinition); + + if (row.translation) { + current.translations = [ + ...current.translations, + { + locale: row.translation.locale, + label: row.translation.label, + }, + ]; + } + grouped.set(row.category.slug, current); + } + + return [...grouped.values()].map((category) => ({ + ...category, + translations: [...category.translations].sort((a, b) => + a.locale.localeCompare(b.locale), + ), })); } } diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/schema.ts index c7679a63..6a1a423f 100644 --- a/apps/api/src/contexts/supplies/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/schema.ts @@ -23,6 +23,7 @@ export const categoriesTable = pgTable('categories', { ), vertical: text('vertical').notNull().default('general'), sort: integer('sort').notNull().default(0), + archivedAt: timestamp('archived_at', { withTimezone: true }), }); export const categoryAliasesTable = pgTable('category_aliases', { diff --git a/apps/api/src/contexts/supplies/infrastructure/http/admin-category.dto.ts b/apps/api/src/contexts/supplies/infrastructure/http/admin-category.dto.ts new file mode 100644 index 00000000..2d589cad --- /dev/null +++ b/apps/api/src/contexts/supplies/infrastructure/http/admin-category.dto.ts @@ -0,0 +1,165 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +const CATEGORY_SLUG_PATTERN = /^[a-z0-9_]+$/; + +export class CategoryTranslationDto { + @ApiProperty({ example: 'fr' }) + @IsString() + locale!: string; + + @ApiProperty({ example: 'Nourriture' }) + @IsString() + label!: string; +} + +export class CreateCategoryDto { + @ApiProperty({ example: 'baby_food' }) + @IsString() + @Matches(CATEGORY_SLUG_PATTERN, { + message: 'slug must use lowercase letters, numbers or underscores', + }) + slug!: string; + + @ApiProperty({ example: 'Alimentos para bebé' }) + @IsString() + labelEs!: string; + + @ApiProperty({ example: 'Baby food' }) + @IsString() + labelEn!: string; + + @ApiPropertyOptional({ + example: 'food', + nullable: true, + description: 'Parent category slug, or null for a top-level category', + }) + @IsOptional() + @IsString() + parentSlug!: string | null; + + @ApiProperty({ example: 'general' }) + @IsString() + vertical!: string; + + @ApiProperty({ example: 140 }) + @IsInt() + sort!: number; + + @ApiPropertyOptional({ + type: [CategoryTranslationDto], + description: 'Additional locale labels to persist in category_translations', + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CategoryTranslationDto) + translations?: CategoryTranslationDto[]; +} + +export class UpdateCategoryDto { + @ApiPropertyOptional({ example: 'baby_food' }) + @IsOptional() + @IsString() + @Matches(CATEGORY_SLUG_PATTERN, { + message: 'slug must use lowercase letters, numbers or underscores', + }) + slug?: string; + + @ApiPropertyOptional({ example: 'Alimentos para bebé' }) + @IsOptional() + @IsString() + labelEs?: string; + + @ApiPropertyOptional({ example: 'Baby food' }) + @IsOptional() + @IsString() + labelEn?: string; + + @ApiPropertyOptional({ + example: 'food', + nullable: true, + description: 'Parent category slug, or null for a top-level category', + }) + @IsOptional() + @IsString() + parentSlug?: string | null; + + @ApiPropertyOptional({ example: 'general' }) + @IsOptional() + @IsString() + vertical?: string; + + @ApiPropertyOptional({ example: 140 }) + @IsOptional() + @IsInt() + sort?: number; + + @ApiPropertyOptional({ + type: [CategoryTranslationDto], + description: 'Replace the category_translation rows with this set', + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CategoryTranslationDto) + translations?: CategoryTranslationDto[]; + + @ApiPropertyOptional({ + example: false, + description: 'True to hide/archive the category, false to restore it', + }) + @IsOptional() + @IsBoolean() + archived?: boolean; +} + +export class CategoryAdminDto { + @ApiProperty({ example: 'baby_food' }) + slug!: string; + + @ApiProperty({ + example: 'Alimentos para bebé', + description: 'Localized label', + }) + label!: string; + + @ApiProperty({ example: 'Alimentos para bebé' }) + labelEs!: string; + + @ApiProperty({ example: 'Baby food' }) + labelEn!: string; + + @ApiProperty({ + example: 'food', + nullable: true, + type: String, + description: 'Parent category slug, or null for a top-level category', + }) + parentSlug!: string | null; + + @ApiProperty({ example: 'general' }) + vertical!: string; + + @ApiProperty({ example: 140 }) + sort!: number; + + @ApiProperty({ + example: null, + nullable: true, + description: 'Soft-archive timestamp', + }) + archivedAt!: string | null; + + @ApiProperty({ type: [CategoryTranslationDto] }) + translations!: CategoryTranslationDto[]; +} diff --git a/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts b/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts new file mode 100644 index 00000000..f6ba2f99 --- /dev/null +++ b/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.spec.ts @@ -0,0 +1,83 @@ +import { BadRequestException } from '@nestjs/common'; +import { CategoriesAdminController } from './categories-admin.controller'; +import { CategoryDefinition } from '../../domain/category-definition'; + +describe('CategoriesAdminController', () => { + const category: CategoryDefinition = { + slug: 'baby_food', + labelEs: 'Alimentos para bebé', + labelEn: 'Baby food', + parentSlug: 'food', + vertical: 'general', + sort: 140, + archivedAt: null, + translations: [ + { locale: 'es', label: 'Alimentos para bebé' }, + { locale: 'en', label: 'Baby food' }, + { locale: 'fr', label: 'Nourriture pour bébé' }, + ], + }; + + it('lists and localizes categories for admin', async () => { + const listCategories = { execute: jest.fn().mockResolvedValue([category]) }; + const controller = new CategoriesAdminController( + listCategories as never, + { execute: jest.fn() } as never, + { execute: jest.fn() } as never, + ); + + const result = await controller.list('fr', 'fr-FR,fr;q=0.9'); + + expect(listCategories.execute).toHaveBeenCalledWith({ + includeArchived: true, + }); + expect(result[0]?.label).toBe('Nourriture pour bébé'); + }); + + it('creates a category and maps the payload to the write command', async () => { + const createCategory = { execute: jest.fn().mockResolvedValue(category) }; + const controller = new CategoriesAdminController( + { execute: jest.fn() } as never, + createCategory as never, + { execute: jest.fn() } as never, + ); + + const result = await controller.create( + { + slug: 'baby_food', + labelEs: 'Alimentos para bebé', + labelEn: 'Baby food', + parentSlug: 'food', + vertical: 'general', + sort: 140, + translations: [{ locale: 'fr', label: 'Nourriture pour bébé' }], + }, + 'fr', + 'fr-FR,fr;q=0.9', + ); + + expect(createCategory.execute).toHaveBeenCalledWith({ + slug: 'baby_food', + labelEs: 'Alimentos para bebé', + labelEn: 'Baby food', + parentSlug: 'food', + vertical: 'general', + sort: 140, + archivedAt: null, + translations: [{ locale: 'fr', label: 'Nourriture pour bébé' }], + }); + expect(result.label).toBe('Nourriture pour bébé'); + }); + + it('rejects delete for core slugs', async () => { + const controller = new CategoriesAdminController( + { execute: jest.fn() } as never, + { execute: jest.fn() } as never, + { execute: jest.fn() } as never, + ); + + await expect(controller.delete('food')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.ts b/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.ts new file mode 100644 index 00000000..238c0149 --- /dev/null +++ b/apps/api/src/contexts/supplies/infrastructure/http/categories-admin.controller.ts @@ -0,0 +1,237 @@ +import { + BadRequestException, + ConflictException, + Controller, + Body, + Delete, + Get, + Headers, + HttpCode, + NotFoundException, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiConflictResponse, + ApiForbiddenResponse, + ApiHeader, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiUnauthorizedResponse, + ApiCreatedResponse, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../../identity/infrastructure/http/jwt-auth.guard'; +import { PermissionGuard } from '../../../identity/infrastructure/http/permission.guard'; +import { RequirePermission } from '../../../identity/infrastructure/http/require-permission.decorator'; +import { Category } from '../../domain/category'; +import { CategoryDefinition } from '../../domain/category-definition'; +import { CreateCategory } from '../../application/create-category'; +import { + CategoryAlreadyExistsError, + CategoryImmutableSlugError, + CategoryNotFoundError, + CategoryParentNotFoundError, + CategoryValidationError, +} from '../../application/category-admin.errors'; +import { ListCategories } from '../../application/list-categories'; +import { UpdateCategory } from '../../application/update-category'; +import { + CategoryAdminDto, + CreateCategoryDto, + UpdateCategoryDto, +} from './admin-category.dto'; +import { localizedText, resolveLocale } from './locale'; +import { CategoryWriteInput } from '../../domain/ports/category.repository'; + +@ApiTags('categories') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller('categories/admin') +export class CategoriesAdminController { + constructor( + private readonly listCategories: ListCategories, + private readonly createCategory: CreateCategory, + private readonly updateCategory: UpdateCategory, + ) {} + + @Get() + @RequirePermission('catalogue:manage') + @ApiOperation({ + summary: 'List the full category taxonomy for admin (archived included)', + }) + @ApiHeader({ + name: 'Accept-Language', + required: false, + description: + 'Fallback locale header (es, en or a custom translation locale)', + }) + @ApiQuery({ + name: 'locale', + required: false, + description: 'Preferred locale', + }) + @ApiOkResponse({ type: [CategoryAdminDto] }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ description: 'Missing catalogue:manage permission' }) + async list( + @Query('locale') localeParam?: string, + @Headers('accept-language') acceptLanguage?: string, + ): Promise { + const locale = resolveLocale(localeParam, acceptLanguage); + const categories = await this.listCategories.execute({ + includeArchived: true, + }); + return categories.map((category) => this.toDto(category, locale)); + } + + @Post() + @RequirePermission('catalogue:manage') + @ApiOperation({ summary: 'Create a category or subcategory' }) + @ApiCreatedResponse({ type: CategoryAdminDto }) + @ApiBadRequestResponse({ description: 'Invalid category payload' }) + @ApiConflictResponse({ description: 'Category slug already exists' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ description: 'Missing catalogue:manage permission' }) + async create( + @Body() dto: CreateCategoryDto, + @Query('locale') localeParam?: string, + @Headers('accept-language') acceptLanguage?: string, + ): Promise { + const locale = resolveLocale(localeParam, acceptLanguage); + const created = await this.runCategoryCommand(() => + this.createCategory.execute(this.toWriteInput(dto)), + ); + return this.toDto(created, locale); + } + + @Patch(':slug') + @RequirePermission('catalogue:manage') + @ApiOperation({ summary: 'Update a category, subcategory or archive flag' }) + @ApiParam({ name: 'slug', description: 'Current category slug' }) + @ApiOkResponse({ type: CategoryAdminDto }) + @ApiBadRequestResponse({ description: 'Invalid category payload' }) + @ApiNotFoundResponse({ description: 'Category not found' }) + @ApiConflictResponse({ description: 'Category slug already exists' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ description: 'Missing catalogue:manage permission' }) + async update( + @Param('slug') slug: string, + @Body() dto: UpdateCategoryDto, + @Query('locale') localeParam?: string, + @Headers('accept-language') acceptLanguage?: string, + ): Promise { + const locale = resolveLocale(localeParam, acceptLanguage); + const updated = await this.runCategoryCommand(() => + this.updateCategory.execute(slug, this.toUpdateCommand(dto)), + ); + return this.toDto(updated, locale); + } + + @Delete(':slug') + @HttpCode(204) + @RequirePermission('catalogue:manage') + @ApiOperation({ + summary: 'Archive a custom category (core slugs are protected)', + }) + @ApiParam({ name: 'slug', description: 'Category slug to archive' }) + @ApiNoContentResponse({ description: 'Category archived' }) + @ApiNotFoundResponse({ description: 'Category not found' }) + @ApiBadRequestResponse({ + description: 'Core category slug cannot be archived by delete', + }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ description: 'Missing catalogue:manage permission' }) + async delete(@Param('slug') slug: string): Promise { + if (Object.values(Category).includes(slug as Category)) { + throw new BadRequestException( + `Core category slug cannot be deleted: ${slug}`, + ); + } + await this.runCategoryCommand(() => + this.updateCategory.execute(slug, { archived: true }), + ); + } + + private toDto( + category: CategoryDefinition, + locale: string, + ): CategoryAdminDto { + return { + slug: category.slug, + label: localizedText(category, locale), + labelEs: category.labelEs, + labelEn: category.labelEn, + parentSlug: category.parentSlug, + vertical: category.vertical, + sort: category.sort, + archivedAt: category.archivedAt + ? category.archivedAt.toISOString() + : null, + translations: category.translations.map((translation) => ({ + locale: translation.locale, + label: translation.label, + })), + }; + } + + private toWriteInput(dto: CreateCategoryDto): CategoryWriteInput { + return { + slug: dto.slug, + labelEs: dto.labelEs, + labelEn: dto.labelEn, + parentSlug: dto.parentSlug ?? null, + vertical: dto.vertical, + sort: dto.sort, + archivedAt: null, + translations: dto.translations ?? [], + }; + } + + private toUpdateCommand(dto: UpdateCategoryDto) { + return { + slug: dto.slug, + labelEs: dto.labelEs, + labelEn: dto.labelEn, + parentSlug: dto.parentSlug, + vertical: dto.vertical, + sort: dto.sort, + archived: dto.archived, + translations: dto.translations, + }; + } + + private async runCategoryCommand(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + throw this.toHttpError(error); + } + } + + private toHttpError(error: unknown): Error { + if (error instanceof CategoryNotFoundError) { + return new NotFoundException(error.message); + } + if ( + error instanceof CategoryAlreadyExistsError || + error instanceof CategoryParentNotFoundError || + error instanceof CategoryImmutableSlugError || + error instanceof CategoryValidationError + ) { + return error instanceof CategoryAlreadyExistsError + ? new ConflictException(error.message) + : new BadRequestException(error.message); + } + return error instanceof Error ? error : new Error('Unknown category error'); + } +} diff --git a/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts b/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts new file mode 100644 index 00000000..b2704f33 --- /dev/null +++ b/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.spec.ts @@ -0,0 +1,38 @@ +import { CategoriesController } from './categories.controller'; +import { CategoryDefinition } from '../../domain/category-definition'; + +describe('CategoriesController', () => { + const categories: CategoryDefinition[] = [ + { + slug: 'food', + labelEs: 'Alimentos', + labelEn: 'Food', + parentSlug: null, + vertical: 'general', + sort: 1, + archivedAt: null, + translations: [ + { locale: 'es', label: 'Alimentos' }, + { locale: 'en', label: 'Food' }, + { locale: 'fr', label: 'Nourriture' }, + ], + }, + ]; + + it('localizes the label and keeps the base labels', async () => { + const controller = new CategoriesController({ + execute: () => Promise.resolve(categories), + }); + + const [en, es, fr] = await Promise.all([ + controller.list('en', 'en-US,en;q=0.9'), + controller.list('es', 'es-VE,es;q=0.9'), + controller.list('fr', 'fr-FR,fr;q=0.9'), + ]); + + expect(en[0]?.label).toBe('Food'); + expect(en[0]?.labelEs).toBe('Alimentos'); + expect(es[0]?.label).toBe('Alimentos'); + expect(fr[0]?.label).toBe('Nourriture'); + }); +}); diff --git a/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.ts b/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.ts index 2453e302..db521d0a 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/categories.controller.ts @@ -1,7 +1,14 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger'; +import { Controller, Get, Headers, Query } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiOkResponse, + ApiHeader, + ApiQuery, +} from '@nestjs/swagger'; import { ListCategories } from '../../application/list-categories'; import { CategoryDto } from './category-response.dto'; +import { localizedText, resolveLocale } from './locale'; @ApiTags('categories') @Controller() @@ -12,8 +19,32 @@ export class CategoriesController { @ApiOperation({ summary: 'List the shared category taxonomy (slug + labels + hierarchy)', }) + @ApiHeader({ + name: 'Accept-Language', + required: false, + description: + 'Fallback locale header (es, en or a custom translation locale)', + }) + @ApiQuery({ + name: 'locale', + required: false, + description: 'Preferred locale', + }) @ApiOkResponse({ description: 'The category taxonomy', type: [CategoryDto] }) - list(): Promise { - return this.listCategories.execute(); + async list( + @Query('locale') localeParam?: string, + @Headers('accept-language') acceptLanguage?: string, + ): Promise { + const locale = resolveLocale(localeParam, acceptLanguage); + const categories = await this.listCategories.execute(); + return categories.map((category) => ({ + slug: category.slug, + label: localizedText(category, locale), + labelEs: category.labelEs, + labelEn: category.labelEn, + parentSlug: category.parentSlug, + vertical: category.vertical, + sort: category.sort, + })); } } diff --git a/apps/api/src/contexts/supplies/infrastructure/http/category-response.dto.ts b/apps/api/src/contexts/supplies/infrastructure/http/category-response.dto.ts index cec38198..91642fd1 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/category-response.dto.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/category-response.dto.ts @@ -8,6 +8,9 @@ export class CategoryDto { @ApiProperty({ example: 'medicines' }) slug!: string; + @ApiProperty({ example: 'Medicamentos', description: 'Localized label' }) + label!: string; + @ApiProperty({ example: 'Medicamentos' }) labelEs!: string; diff --git a/apps/api/src/contexts/supplies/infrastructure/http/locale.ts b/apps/api/src/contexts/supplies/infrastructure/http/locale.ts new file mode 100644 index 00000000..86c37daf --- /dev/null +++ b/apps/api/src/contexts/supplies/infrastructure/http/locale.ts @@ -0,0 +1,37 @@ +import { CategoryDefinition } from '../../domain/category-definition'; + +export type CategoryLocale = 'es' | 'en' | string; + +export function resolveLocale( + localeParam?: string, + acceptLanguage?: string, +): CategoryLocale { + const raw = (localeParam ?? acceptLanguage ?? 'es') + .split(',')[0] + .split(';')[0] + .trim() + .toLowerCase(); + const normalized = raw.split('-')[0]; + if (normalized === 'en') return 'en'; + if (normalized === 'es') return 'es'; + return normalized === '' ? 'es' : normalized; +} + +export function localizedText( + category: CategoryDefinition, + locale: CategoryLocale, +): string { + const translated = category.translations.find( + (item) => item.locale === locale, + ); + if (translated) { + return translated.label; + } + if (locale === 'en') { + return category.labelEn; + } + if (locale === 'es') { + return category.labelEs; + } + return category.labelEs; +} diff --git a/apps/api/src/contexts/supplies/supplies.module.ts b/apps/api/src/contexts/supplies/supplies.module.ts index 13431504..2aa74215 100644 --- a/apps/api/src/contexts/supplies/supplies.module.ts +++ b/apps/api/src/contexts/supplies/supplies.module.ts @@ -17,6 +17,8 @@ import { DrizzleCategoryRepository } from './infrastructure/drizzle/drizzle-cate import { DrizzleContainerRepository } from './infrastructure/drizzle/drizzle-container.repository'; import { DrizzleContainerAuthorizationLookup } from './infrastructure/drizzle/drizzle-container-authorization-lookup'; import { ListCategories } from './application/list-categories'; +import { CreateCategory } from './application/create-category'; +import { UpdateCategory } from './application/update-category'; import { CreateContainer } from './application/create-container'; import { AddLineToContainer } from './application/add-line-to-container'; import { RemoveLineFromContainer } from './application/remove-line-from-container'; @@ -26,6 +28,7 @@ import { MoveContainer } from './application/move-container'; import { GetContainer } from './application/get-container'; import { ListContainers } from './application/list-containers'; import { CategoriesController } from './infrastructure/http/categories.controller'; +import { CategoriesAdminController } from './infrastructure/http/categories-admin.controller'; import { ContainerController } from './infrastructure/http/containers.controller'; import { IdentityModule } from '../identity/infrastructure/identity.module'; @@ -56,6 +59,20 @@ const listCategoriesProvider = { new ListCategories(repo), }; +const createCategoryProvider = { + provide: CreateCategory, + inject: [CATEGORY_REPOSITORY], + useFactory: (repo: CategoryRepository): CreateCategory => + new CreateCategory(repo), +}; + +const updateCategoryProvider = { + provide: UpdateCategory, + inject: [CATEGORY_REPOSITORY], + useFactory: (repo: CategoryRepository): UpdateCategory => + new UpdateCategory(repo), +}; + const createContainerProvider = { provide: CreateContainer, inject: [CONTAINER_REPOSITORY], @@ -112,12 +129,18 @@ const listContainersProvider = { */ @Module({ imports: [DatabaseModule, IdentityModule], - controllers: [CategoriesController, ContainerController], + controllers: [ + CategoriesController, + CategoriesAdminController, + ContainerController, + ], providers: [ categoryRepositoryProvider, containerRepositoryProvider, containerAuthorizationLookupProvider, listCategoriesProvider, + createCategoryProvider, + updateCategoryProvider, createContainerProvider, addLineToContainerProvider, removeLineFromContainerProvider, From d3ee77de5d3567ceda497cba4e80ed115d7fc753 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 22:13:56 +0200 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20lint=20de=20categor=C3=ADas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supplies/application/create-category.ts | 2 +- .../drizzle/drizzle-category.repository.ts | 22 +++++++++---------- .../supplies/infrastructure/http/locale.ts | 2 +- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/api/src/contexts/supplies/application/create-category.ts b/apps/api/src/contexts/supplies/application/create-category.ts index b5aaae9c..ecc9490e 100644 --- a/apps/api/src/contexts/supplies/application/create-category.ts +++ b/apps/api/src/contexts/supplies/application/create-category.ts @@ -9,7 +9,7 @@ import { CategoryWriteInput, } from '../domain/ports/category.repository'; -export interface CreateCategoryCommand extends CategoryWriteInput {} +export type CreateCategoryCommand = CategoryWriteInput; export class CreateCategory { constructor(private readonly repo: CategoryRepository) {} diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts index 761fdd72..238a9ae6 100644 --- a/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/drizzle-category.repository.ts @@ -240,18 +240,16 @@ export class DrizzleCategoryRepository implements CategoryRepository { const grouped = new Map(); for (const row of rows) { - const current = - grouped.get(row.category.slug) ?? - ({ - slug: row.category.slug, - labelEs: row.category.labelEs, - labelEn: row.category.labelEn, - parentSlug: row.category.parentSlug ?? null, - vertical: row.category.vertical, - sort: row.category.sort, - archivedAt: row.category.archivedAt ?? null, - translations: [], - } as CategoryDefinition); + const current = grouped.get(row.category.slug) ?? { + slug: row.category.slug, + labelEs: row.category.labelEs, + labelEn: row.category.labelEn, + parentSlug: row.category.parentSlug ?? null, + vertical: row.category.vertical, + sort: row.category.sort, + archivedAt: row.category.archivedAt ?? null, + translations: [], + }; if (row.translation) { current.translations = [ diff --git a/apps/api/src/contexts/supplies/infrastructure/http/locale.ts b/apps/api/src/contexts/supplies/infrastructure/http/locale.ts index 86c37daf..94f2aef7 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/locale.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/locale.ts @@ -1,6 +1,6 @@ import { CategoryDefinition } from '../../domain/category-definition'; -export type CategoryLocale = 'es' | 'en' | string; +export type CategoryLocale = string; export function resolveLocale( localeParam?: string, From 68359b1bbbe3a860b348046caf7f1488c13608ad Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 13:04:49 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(supplies):=20soft=20link=20de=20supp?= =?UTF-8?q?ly=20en=20l=C3=ADneas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drizzle/0042_supply_line_supply_id.sql | 14 ++++++++++++++ .../infrastructure/logistics.module.ts | 13 +++++++++---- .../contexts/needs/application/create-need.ts | 2 ++ .../needs/infrastructure/drizzle/schema.ts | 7 ++++++- .../infrastructure/http/needs.controller.ts | 1 + .../needs/infrastructure/needs.module.ts | 13 +++++++++---- .../contexts/offers/application/edit-offer.ts | 2 ++ .../application/get-donation-intake-by-id.ts | 2 ++ .../get-donation-intake-tracking.ts | 2 ++ .../offers/application/submit-offer.ts | 2 ++ .../src/contexts/offers/domain/intake-line.ts | 9 +-------- .../drizzle/donation-intake-schema.ts | 7 ++++++- .../offers/infrastructure/drizzle/schema.ts | 7 ++++++- .../http/donation-intakes.controller.ts | 1 + .../infrastructure/http/offers.controller.ts | 2 ++ .../offers/infrastructure/offers.module.ts | 13 +++++++++---- .../application/register-resource.ts | 2 ++ .../infrastructure/donation-events.worker.ts | 7 ++++--- .../infrastructure/drizzle/schema.ts | 7 ++++++- .../http/resources.controller.ts | 2 ++ .../infrastructure/resources.module.ts | 13 +++++++++---- .../supplies/domain/supply-line.spec.ts | 18 +++++++++++++++++- .../contexts/supplies/domain/supply-line.ts | 19 +++++++++++++++++-- .../drizzle/supply-line-columns.ts | 13 ++++++++++--- .../infrastructure/http/supply-line.dto.ts | 19 +++++++++++++++++++ apps/api/src/shared/bullmq-connection.ts | 10 ++++++++++ apps/web/src/lib/supply-lines.test.ts | 19 +++++++++++++++++++ apps/web/src/lib/supply-lines.ts | 15 ++++++++++++++- packages/api-client/src/schema.ts | 15 +++++++++++++++ 29 files changed, 218 insertions(+), 38 deletions(-) create mode 100644 apps/api/drizzle/0042_supply_line_supply_id.sql create mode 100644 apps/api/src/shared/bullmq-connection.ts diff --git a/apps/api/drizzle/0042_supply_line_supply_id.sql b/apps/api/drizzle/0042_supply_line_supply_id.sql new file mode 100644 index 00000000..f79b5ba4 --- /dev/null +++ b/apps/api/drizzle/0042_supply_line_supply_id.sql @@ -0,0 +1,14 @@ +-- Soft link from each supply line to the canonical supplies master data. +-- Nullable and legacy-safe: existing rows can stay unlinked. + +ALTER TABLE "need_items" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; + +ALTER TABLE "offer_items" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; + +ALTER TABLE "resource_items" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; + +ALTER TABLE "donation_intake_lines" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; diff --git a/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts b/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts index e777afd8..3ba5b718 100644 --- a/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts +++ b/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { LogisticsController } from './http/logistics.controller'; @@ -50,6 +50,7 @@ import { DrizzleShipmentAuthorizationLookup } from './drizzle/drizzle-shipment-a import { DrizzleCapacityEmergencyLookup } from './drizzle/drizzle-capacity-emergency-lookup'; import { DrizzleResourceLocationLookup } from './drizzle/drizzle-resource-location-lookup'; import { DrizzleEmergencyStatusReader } from '../../../shared/drizzle-emergency-status-reader'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; import { BullMqShipmentEventBus } from './bullmq-shipment-event-bus'; import { IdentityModule } from '../../identity/infrastructure/identity.module'; // MEMBERSHIP_REPOSITORY is exported by IdentityModule and consumed by the @@ -69,7 +70,7 @@ export const SHIPMENT_EVENT_QUEUE = Symbol('ShipmentEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -77,8 +78,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/needs/application/create-need.ts b/apps/api/src/contexts/needs/application/create-need.ts index 6c04cd70..d872d0eb 100644 --- a/apps/api/src/contexts/needs/application/create-need.ts +++ b/apps/api/src/contexts/needs/application/create-need.ts @@ -17,6 +17,7 @@ export interface CreateNeedItemCommand { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; /** Presentation / route of administration (#61). Optional. */ presentation?: string | null; expiresAt?: string | null; @@ -73,6 +74,7 @@ export class CreateNeed { quantity: i.quantity, unit: i.unit, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, }), diff --git a/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts index 886272b8..6efdd81a 100644 --- a/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts @@ -7,6 +7,7 @@ import { doublePrecision, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; export const needsTable = pgTable('needs', { id: uuid('id').primaryKey(), @@ -38,5 +39,9 @@ export const needItemsTable = pgTable('need_items', { needId: uuid('need_id') .notNull() .references(() => needsTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set null', + }), + ), }); diff --git a/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts b/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts index da6cf23e..07268230 100644 --- a/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts +++ b/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts @@ -127,6 +127,7 @@ export class NeedsController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, })), diff --git a/apps/api/src/contexts/needs/infrastructure/needs.module.ts b/apps/api/src/contexts/needs/infrastructure/needs.module.ts index 75fe1ab1..29a004dc 100644 --- a/apps/api/src/contexts/needs/infrastructure/needs.module.ts +++ b/apps/api/src/contexts/needs/infrastructure/needs.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { NeedsController } from './http/needs.controller'; @@ -45,12 +45,13 @@ import { VolunteerRepository } from '../../volunteers/domain/ports/volunteer.rep import { TaskRepository } from '../../volunteers/domain/ports/task.repository'; import { DrizzleVolunteerRepository } from '../../volunteers/infrastructure/drizzle/drizzle-volunteer.repository'; import { DrizzleTaskRepository } from '../../volunteers/infrastructure/drizzle/drizzle-task.repository'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; export const EVENT_QUEUE = Symbol('NeedsEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -58,8 +59,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/offers/application/edit-offer.ts b/apps/api/src/contexts/offers/application/edit-offer.ts index c724b03b..1af261d7 100644 --- a/apps/api/src/contexts/offers/application/edit-offer.ts +++ b/apps/api/src/contexts/offers/application/edit-offer.ts @@ -17,6 +17,7 @@ export interface EditOfferItemCommand { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; presentation: string | null; } @@ -59,6 +60,7 @@ export class EditOffer { quantity: i.quantity, unit: i.unit, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation, }), ); diff --git a/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts b/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts index 343e762b..9022027d 100644 --- a/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts +++ b/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts @@ -10,6 +10,7 @@ export interface DonationIntakeLineView { quantity: number; unit: string | null; category: Category; + supplyId: string | null; presentation: string | null; expiresAt: string | null; } @@ -60,6 +61,7 @@ export class GetDonationIntakeById { quantity: line.quantity, unit: line.unit, category: line.category, + supplyId: line.supplyId ?? null, presentation: line.presentation ?? null, expiresAt: line.expiresAt ?? null, })), diff --git a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts index 150d9416..30a77c74 100644 --- a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts +++ b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts @@ -9,6 +9,7 @@ export interface DonationIntakeTrackingLine { quantity: number; unit: string | null; category: Category; + supplyId: string | null; presentation: string | null; } @@ -63,6 +64,7 @@ export class GetDonationIntakeTracking { quantity: line.quantity, unit: line.unit, category: line.category, + supplyId: line.supplyId ?? null, presentation: line.presentation ?? null, })), }; diff --git a/apps/api/src/contexts/offers/application/submit-offer.ts b/apps/api/src/contexts/offers/application/submit-offer.ts index 6d9cad1c..4974367f 100644 --- a/apps/api/src/contexts/offers/application/submit-offer.ts +++ b/apps/api/src/contexts/offers/application/submit-offer.ts @@ -39,6 +39,7 @@ export interface SubmitOfferItemCommand { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; presentation: string | null; } @@ -96,6 +97,7 @@ export class SubmitOffer { quantity: i.quantity, unit: i.unit, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation, }), ); diff --git a/apps/api/src/contexts/offers/domain/intake-line.ts b/apps/api/src/contexts/offers/domain/intake-line.ts index af9c554f..c82acfae 100644 --- a/apps/api/src/contexts/offers/domain/intake-line.ts +++ b/apps/api/src/contexts/offers/domain/intake-line.ts @@ -36,14 +36,7 @@ export class IntakeLine { return new IntakeLine( s.id, s.sortOrder, - SupplyLine.fromSnapshot({ - name: s.name, - quantity: s.quantity, - unit: s.unit, - category: s.category, - presentation: s.presentation ?? null, - expiresAt: s.expiresAt ?? null, - }), + SupplyLine.fromSnapshot(s), ); } diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts index f7ed229d..cd30366b 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts @@ -1,5 +1,6 @@ import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; export const donationIntakesTable = pgTable('donation_intakes', { id: uuid('id').primaryKey(), @@ -25,6 +26,10 @@ export const donationIntakeLinesTable = pgTable('donation_intake_lines', { intakeId: uuid('intake_id') .notNull() .references(() => donationIntakesTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set null', + }), + ), sortOrder: integer('sort_order').notNull().default(0), }); diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts index 7c41d054..3e5210a1 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts @@ -6,6 +6,7 @@ import { doublePrecision, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; export const offersTable = pgTable('offers', { id: uuid('id').primaryKey(), @@ -33,5 +34,9 @@ export const offerItemsTable = pgTable('offer_items', { offerId: uuid('offer_id') .notNull() .references(() => offersTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set null', + }), + ), }); diff --git a/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts b/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts index 330e1dcd..a0388202 100644 --- a/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts +++ b/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts @@ -80,6 +80,7 @@ function mapItems(items: CreateDonationIntakeDto['items']): SupplyLineProps[] { quantity: item.quantity, unit: item.unit ?? null, category: item.category, + supplyId: item.supplyId ?? null, presentation: item.presentation ?? null, expiresAt: item.expiresAt ?? null, })); diff --git a/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts b/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts index fb618d32..5f7d6928 100644 --- a/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts +++ b/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts @@ -125,6 +125,7 @@ export class OffersController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, })), location: { @@ -313,6 +314,7 @@ export class OffersController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, })); } diff --git a/apps/api/src/contexts/offers/infrastructure/offers.module.ts b/apps/api/src/contexts/offers/infrastructure/offers.module.ts index 6dd4c8ae..89cca467 100644 --- a/apps/api/src/contexts/offers/infrastructure/offers.module.ts +++ b/apps/api/src/contexts/offers/infrastructure/offers.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { OffersController } from './http/offers.controller'; @@ -69,6 +69,7 @@ import { NotificationsPort, } from '../../notifications/domain/ports/notifications.port'; import { NotificationsModule } from '../../notifications/infrastructure/notifications.module'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; // MEMBERSHIP_REPOSITORY and OFFER_EMERGENCY_LOOKUP are exported by IdentityModule // and consumed by OffersController via @Inject — no factory needed here. @@ -76,7 +77,7 @@ export const OFFER_EVENT_QUEUE = Symbol('OffersEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -84,8 +85,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/resources/application/register-resource.ts b/apps/api/src/contexts/resources/application/register-resource.ts index cdf6633a..4be7215a 100644 --- a/apps/api/src/contexts/resources/application/register-resource.ts +++ b/apps/api/src/contexts/resources/application/register-resource.ts @@ -38,6 +38,7 @@ export interface RegisterResourceCommand { quantity: number; unit?: string | null; category: Category; + supplyId?: string | null; presentation?: string | null; expiresAt?: string | null; }>; @@ -84,6 +85,7 @@ export class RegisterResource { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, }), diff --git a/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts b/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts index 3bf83626..75306a58 100644 --- a/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts +++ b/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts @@ -1,8 +1,9 @@ import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Worker, Job } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { ReceiveDonationIntoInventory } from '../application/receive-donation-into-inventory'; import { SupplyLineSnapshot } from '../../supplies/domain/supply-line'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; interface DomainEventJobData { name: string; @@ -28,7 +29,7 @@ const DONATION_RECEIVED = 'donation_intake.received'; export class DonationEventsWorker implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(DonationEventsWorker.name); private worker: Worker | null = null; - private connection: IORedis | null = null; + private connection: IORedisConnection | null = null; constructor(private readonly receive: ReceiveDonationIntoInventory) {} @@ -40,7 +41,7 @@ export class DonationEventsWorker implements OnModuleInit, OnModuleDestroy { this.worker = new Worker( 'domain-events', (job: Job) => this.handle(job), - { connection: this.connection }, + { connection: toBullMqConnection(this.connection) }, ); this.worker.on('failed', (job, err) => { this.logger.error( diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts index 721ab352..1557186b 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts @@ -8,6 +8,7 @@ import { boolean, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; export const resourcesTable = pgTable('resources', { id: uuid('id').primaryKey(), @@ -76,5 +77,9 @@ export const resourceItemsTable = pgTable('resource_items', { resourceId: uuid('resource_id') .notNull() .references(() => resourcesTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set null', + }), + ), }); diff --git a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts index edeb6cc4..44029c9f 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts @@ -129,6 +129,7 @@ export class ResourcesController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, })), @@ -167,6 +168,7 @@ export class ResourcesController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, })), }); diff --git a/apps/api/src/contexts/resources/infrastructure/resources.module.ts b/apps/api/src/contexts/resources/infrastructure/resources.module.ts index 18d5ba65..d37aa8f3 100644 --- a/apps/api/src/contexts/resources/infrastructure/resources.module.ts +++ b/apps/api/src/contexts/resources/infrastructure/resources.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { ResourcesController } from './http/resources.controller'; @@ -69,12 +69,13 @@ import { ResourceValidityReportRepository, } from '../domain/ports/resource-validity-report.repository'; import { DrizzleResourceValidityReportRepository } from './drizzle/drizzle-resource-validity-report.repository'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; export const EVENT_QUEUE = Symbol('ResourcesEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -82,8 +83,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/supplies/domain/supply-line.spec.ts b/apps/api/src/contexts/supplies/domain/supply-line.spec.ts index 4efffcc1..b84ad172 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.spec.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.spec.ts @@ -8,12 +8,14 @@ describe('SupplyLine', () => { quantity: 100, unit: 'litros', category: Category.Water, + supplyId: ' 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 ', }); expect(line.name).toBe('Agua embotellada'); expect(line.quantity).toBe(100); expect(line.unit).toBe('litros'); expect(line.category).toBe(Category.Water); + expect(line.supplyId).toBe('1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8'); expect(line.presentation).toBeNull(); }); @@ -26,6 +28,7 @@ describe('SupplyLine', () => { }); expect(line.unit).toBeNull(); + expect(line.supplyId).toBeNull(); expect(line.presentation).toBeNull(); }); @@ -102,12 +105,13 @@ describe('SupplyLine', () => { }, ); - it('round-trips through a snapshot (including presentation and expiresAt)', () => { + it('round-trips through a snapshot including the soft link', () => { const line = SupplyLine.create({ name: 'Budesonida', quantity: 5, unit: null, category: Category.Medicines, + supplyId: 'b3f0c6e0-0f51-4f34-8f2d-8d1f3c0c5b50', presentation: 'inhalador', expiresAt: '2026-07-01', }); @@ -116,4 +120,16 @@ describe('SupplyLine', () => { expect(restored.toSnapshot()).toEqual(line.toSnapshot()); }); + + it('round-trips a null soft link in snapshots', () => { + const line = SupplyLine.fromSnapshot({ + name: 'Agua', + quantity: 1, + unit: null, + category: Category.Food, + supplyId: null, + }); + + expect(line.supplyId).toBeNull(); + }); }); diff --git a/apps/api/src/contexts/supplies/domain/supply-line.ts b/apps/api/src/contexts/supplies/domain/supply-line.ts index e5f12f89..97fe0c7d 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.ts @@ -26,6 +26,7 @@ export interface SupplyLineProps { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; presentation?: string | null; expiresAt?: string | null; } @@ -35,12 +36,20 @@ export interface SupplyLineSnapshot { quantity: number; unit: string | null; category: Category; + /** Soft link to the canonical supply master data, or null when absent. */ + supplyId: string | null; /** Optional (legacy-safe) presentation / route of administration (#61). */ presentation?: string | null; /** Optional freshness date for the line, kept as an ISO date string. */ expiresAt?: string | null; } +function normalizeOptionalText(value?: string | null): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + function normalizeDateOnly(value?: string | null): string | null { if (value == null) return null; const trimmed = value.trim(); @@ -71,6 +80,7 @@ export class SupplyLine { readonly quantity: number; readonly unit: string | null; readonly category: Category; + readonly supplyId: string | null; readonly presentation: string | null; readonly expiresAt: string | null; @@ -79,6 +89,7 @@ export class SupplyLine { this.quantity = props.quantity; this.unit = props.unit; this.category = props.category; + this.supplyId = normalizeOptionalText(props.supplyId); this.presentation = props.presentation ?? null; this.expiresAt = normalizeDateOnly(props.expiresAt); } @@ -97,8 +108,11 @@ export class SupplyLine { quantity: props.quantity, unit: props.unit ?? null, category: props.category, - presentation: props.presentation ?? null, - expiresAt: normalizeDateOnly(props.expiresAt), + ...(props.supplyId === undefined ? {} : { supplyId: props.supplyId }), + ...(props.presentation === undefined + ? {} + : { presentation: props.presentation }), + ...(props.expiresAt === undefined ? {} : { expiresAt: props.expiresAt }), }); } @@ -112,6 +126,7 @@ export class SupplyLine { quantity: this.quantity, unit: this.unit, category: this.category, + supplyId: this.supplyId, presentation: this.presentation, expiresAt: this.expiresAt, }; diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts index 675625fd..df07b8e0 100644 --- a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts @@ -1,10 +1,11 @@ -import { integer, text, timestamp } from 'drizzle-orm/pg-core'; +import { integer, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { Category } from '../../domain/category'; import { SupplyLineSnapshot } from '../../domain/supply-line'; /** * The shared Drizzle columns of a supply line — the canonical material line of - * the platform: name + quantity + unit + category + presentation + expiresAt. + * the platform: name + quantity + unit + category + supplyId + presentation + + * expiresAt. * Spread into every `*_items` child table (`need_items`, `resource_items`, * `offer_items`, `donation_intake_lines`) so the column set can never drift * across contexts. @@ -12,12 +13,15 @@ import { SupplyLineSnapshot } from '../../domain/supply-line'; * A factory (not a shared constant) so each table gets its own fresh column * builders. */ -export function supplyLineColumns() { +export function supplyLineColumns( + supplyIdColumn = uuid('supply_id'), +) { return { name: text('name').notNull(), quantity: integer('quantity').notNull(), unit: text('unit'), category: text('category').notNull(), + supplyId: supplyIdColumn, presentation: text('presentation'), expiresAt: timestamp('expires_at', { withTimezone: true }), }; @@ -29,6 +33,7 @@ export interface SupplyLineRow { quantity: number; unit: string | null; category: string; + supplyId: string | null; presentation: string | null; expiresAt: Date | null; } @@ -51,6 +56,7 @@ export function rowToSupplyLineSnapshot( quantity: row.quantity, unit: row.unit ?? null, category: row.category as Category, + supplyId: row.supplyId ?? null, presentation: row.presentation ?? null, expiresAt: supplyLineDateFromDb(row.expiresAt), }; @@ -63,6 +69,7 @@ export function supplyLineToColumns(line: SupplyLineSnapshot): SupplyLineRow { quantity: line.quantity, unit: line.unit, category: line.category, + supplyId: line.supplyId ?? null, presentation: line.presentation ?? null, expiresAt: supplyLineDateToDb(line.expiresAt), }; diff --git a/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts b/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts index d40292f0..9e4d05e8 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts @@ -4,6 +4,7 @@ import { IsNotEmpty, IsOptional, IsPositive, + IsUUID, IsString, Matches, } from 'class-validator'; @@ -23,6 +24,16 @@ export class SupplyLineDto { @IsNotEmpty() name!: string; + @ApiPropertyOptional({ + example: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + nullable: true, + type: String, + description: 'Optional soft link to the canonical supply master data.', + }) + @IsOptional() + @IsUUID() + supplyId?: string | null; + @ApiProperty({ example: 100, description: 'Quantity (positive integer)' }) @IsInt() @IsPositive() @@ -67,6 +78,14 @@ export class SupplyLineResponseDto { @ApiProperty({ example: 'Water bottles' }) name!: string; + @ApiProperty({ + example: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + nullable: true, + type: String, + description: 'Optional soft link to the canonical supply master data.', + }) + supplyId!: string | null; + @ApiProperty({ example: 100 }) quantity!: number; diff --git a/apps/api/src/shared/bullmq-connection.ts b/apps/api/src/shared/bullmq-connection.ts new file mode 100644 index 00000000..0317907e --- /dev/null +++ b/apps/api/src/shared/bullmq-connection.ts @@ -0,0 +1,10 @@ +import type { Redis as IORedisConnection } from 'ioredis'; + +/** + * BullMQ's connection type is pinned through a different ioredis version in the + * dependency graph, so we centralize the compatibility cast here instead of + * repeating it at every call site. + */ +export function toBullMqConnection(connection: IORedisConnection): never { + return connection as never; +} diff --git a/apps/web/src/lib/supply-lines.test.ts b/apps/web/src/lib/supply-lines.test.ts index db527651..0ba38cb9 100644 --- a/apps/web/src/lib/supply-lines.test.ts +++ b/apps/web/src/lib/supply-lines.test.ts @@ -42,6 +42,25 @@ test('omits a blank unit instead of sending an empty string', () => { ]); }); +test('preserves a soft supply link when present', () => { + const raw = JSON.stringify([ + { + name: 'Agua', + quantity: 12, + category: 'water', + supplyId: ' 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 ', + }, + ]); + assert.deepEqual(parseSupplyLines(raw, REQUIRED), [ + { + name: 'Agua', + quantity: 12, + category: 'water', + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + }, + ]); +}); + test('returns null on malformed JSON or a non-array root', () => { assert.equal(parseSupplyLines('{not json', REQUIRED), null); assert.equal(parseSupplyLines('{"a":1}', REQUIRED), null); diff --git a/apps/web/src/lib/supply-lines.ts b/apps/web/src/lib/supply-lines.ts index 5ec5cfe2..37577f2f 100644 --- a/apps/web/src/lib/supply-lines.ts +++ b/apps/web/src/lib/supply-lines.ts @@ -2,6 +2,9 @@ import type { components } from '@reliefhub/api-client'; type SupplyLineDto = components['schemas']['SupplyLineDto']; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** * Parse the supply lines serialized by a SupplyLine editor (a JSON array in a * hidden `items` input) into the typed request shape. Returns `null` on a @@ -31,7 +34,7 @@ export function parseSupplyLines( const items: SupplyLineDto[] = []; for (const entry of parsed) { if (typeof entry !== 'object' || entry === null) return null; - const { name, quantity, unit, category, expiresAt } = entry as Record< + const { name, quantity, unit, category, supplyId, expiresAt } = entry as Record< string, unknown >; @@ -53,6 +56,13 @@ export function parseSupplyLines( ) { return null; } + if ( + supplyId !== undefined && + supplyId !== null && + (typeof supplyId !== 'string' || !UUID_RE.test(supplyId.trim())) + ) { + return null; + } items.push({ name: name.trim(), quantity, @@ -60,6 +70,9 @@ export function parseSupplyLines( ...(typeof unit === 'string' && unit.trim() !== '' ? { unit: unit.trim() } : {}), + ...(typeof supplyId === 'string' && supplyId.trim() !== '' + ? { supplyId: supplyId.trim() } + : {}), ...(typeof expiresAt === 'string' && expiresAt !== '' ? { expiresAt } : {}), diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 103e6afe..6fec7398 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -2693,6 +2693,11 @@ export interface components { * @enum {string} */ category: "food" | "water" | "hygiene" | "clothing" | "medical" | "shelter" | "tools" | "other" | "medicines" | "medical_equipment" | "medical_supplies" | "medical_personnel"; + /** + * @description Optional soft link to the canonical supply master data. + * @example 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 + */ + supplyId?: string | null; /** * @description Presentation / route of administration: ampolla, EV (intravenoso), inhalador, pastilla, jarabe… Optional, free-form (#61). * @example ampolla @@ -3627,6 +3632,11 @@ export interface components { SupplyLineResponseDto: { /** @example Water bottles */ name: string; + /** + * @description Optional soft link to the canonical supply master data. + * @example 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 + */ + supplyId: string | null; /** @example 100 */ quantity: number; /** @example liters */ @@ -4293,6 +4303,11 @@ export interface components { IntakeLineViewDto: { /** @example Water bottles */ name: string; + /** + * @description Optional soft link to the canonical supply master data. + * @example 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 + */ + supplyId: string | null; /** @example 100 */ quantity: number; /** @example liters */ From 580bc631865831177271f71d98ae8956b323d40b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 13:18:58 +0200 Subject: [PATCH 04/10] fix: formateo tras merge de issue 223 --- apps/api/src/contexts/offers/domain/intake-line.ts | 6 +----- .../supplies/infrastructure/drizzle/supply-line-columns.ts | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/api/src/contexts/offers/domain/intake-line.ts b/apps/api/src/contexts/offers/domain/intake-line.ts index c82acfae..f85677da 100644 --- a/apps/api/src/contexts/offers/domain/intake-line.ts +++ b/apps/api/src/contexts/offers/domain/intake-line.ts @@ -33,11 +33,7 @@ export class IntakeLine { } static fromSnapshot(s: IntakeLineSnapshot): IntakeLine { - return new IntakeLine( - s.id, - s.sortOrder, - SupplyLine.fromSnapshot(s), - ); + return new IntakeLine(s.id, s.sortOrder, SupplyLine.fromSnapshot(s)); } toSnapshot(): IntakeLineSnapshot { diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts index df07b8e0..571970be 100644 --- a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts @@ -13,9 +13,7 @@ import { SupplyLineSnapshot } from '../../domain/supply-line'; * A factory (not a shared constant) so each table gets its own fresh column * builders. */ -export function supplyLineColumns( - supplyIdColumn = uuid('supply_id'), -) { +export function supplyLineColumns(supplyIdColumn = uuid('supply_id')) { return { name: text('name').notNull(), quantity: integer('quantity').notNull(), From 154b59f08b4409172fd2814c9a4a6255e500d1af Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 13:24:11 +0200 Subject: [PATCH 05/10] fix: ci tras merge de issue 223 --- .../{0039_author_attribution.sql => 0044_author_attribution.sql} | 0 ..._supply_line_supply_id.sql => 0045_supply_line_supply_id.sql} | 0 .../offers/application/get-donation-intake-tracking.spec.ts | 1 + 3 files changed, 1 insertion(+) rename apps/api/drizzle/{0039_author_attribution.sql => 0044_author_attribution.sql} (100%) rename apps/api/drizzle/{0042_supply_line_supply_id.sql => 0045_supply_line_supply_id.sql} (100%) diff --git a/apps/api/drizzle/0039_author_attribution.sql b/apps/api/drizzle/0044_author_attribution.sql similarity index 100% rename from apps/api/drizzle/0039_author_attribution.sql rename to apps/api/drizzle/0044_author_attribution.sql diff --git a/apps/api/drizzle/0042_supply_line_supply_id.sql b/apps/api/drizzle/0045_supply_line_supply_id.sql similarity index 100% rename from apps/api/drizzle/0042_supply_line_supply_id.sql rename to apps/api/drizzle/0045_supply_line_supply_id.sql diff --git a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts index 5c60d677..3451ffdc 100644 --- a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts +++ b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts @@ -85,6 +85,7 @@ describe('GetDonationIntakeTracking', () => { quantity: 5, unit: 'l', category: Category.Water, + supplyId: null, presentation: null, }, ]); From fab4b82af39b8368ced716d131f4d672960acbe3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 13:38:56 +0200 Subject: [PATCH 06/10] fix: findings de ci en issue 223 --- ...hor_attribution.sql => 0039_author_attribution.sql} | 0 apps/api/drizzle/0039_categories_admin.sql | 10 ---------- .../resources/infrastructure/donation-events.worker.ts | 5 +++-- apps/api/src/contexts/supplies/domain/supply-line.ts | 8 +++----- apps/api/src/shared/bullmq-connection.ts | 7 +++++-- 5 files changed, 11 insertions(+), 19 deletions(-) rename apps/api/drizzle/{0044_author_attribution.sql => 0039_author_attribution.sql} (100%) delete mode 100644 apps/api/drizzle/0039_categories_admin.sql diff --git a/apps/api/drizzle/0044_author_attribution.sql b/apps/api/drizzle/0039_author_attribution.sql similarity index 100% rename from apps/api/drizzle/0044_author_attribution.sql rename to apps/api/drizzle/0039_author_attribution.sql diff --git a/apps/api/drizzle/0039_categories_admin.sql b/apps/api/drizzle/0039_categories_admin.sql deleted file mode 100644 index 6afafee0..00000000 --- a/apps/api/drizzle/0039_categories_admin.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Category admin support (#221): allow soft-hiding categories without losing --- the shared taxonomy, while keeping the existing public taxonomy seed intact. - -ALTER TABLE categories - ADD COLUMN IF NOT EXISTS archived_at timestamp with time zone; ---> statement-breakpoint - -CREATE INDEX IF NOT EXISTS categories_archived_at_idx - ON categories (archived_at); ---> statement-breakpoint diff --git a/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts b/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts index 75306a58..9346de32 100644 --- a/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts +++ b/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts @@ -37,11 +37,12 @@ export class DonationEventsWorker implements OnModuleInit, OnModuleDestroy { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); // BullMQ workers need a dedicated connection with blocking commands enabled. - this.connection = new IORedis(url, { maxRetriesPerRequest: null }); + const connection = new IORedis(url, { maxRetriesPerRequest: null }); + this.connection = connection; this.worker = new Worker( 'domain-events', (job: Job) => this.handle(job), - { connection: toBullMqConnection(this.connection) }, + { connection: toBullMqConnection(connection) }, ); this.worker.on('failed', (job, err) => { this.logger.error( diff --git a/apps/api/src/contexts/supplies/domain/supply-line.ts b/apps/api/src/contexts/supplies/domain/supply-line.ts index 97fe0c7d..80a8dadc 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.ts @@ -108,11 +108,9 @@ export class SupplyLine { quantity: props.quantity, unit: props.unit ?? null, category: props.category, - ...(props.supplyId === undefined ? {} : { supplyId: props.supplyId }), - ...(props.presentation === undefined - ? {} - : { presentation: props.presentation }), - ...(props.expiresAt === undefined ? {} : { expiresAt: props.expiresAt }), + supplyId: props.supplyId ?? null, + presentation: props.presentation ?? null, + expiresAt: props.expiresAt ?? null, }); } diff --git a/apps/api/src/shared/bullmq-connection.ts b/apps/api/src/shared/bullmq-connection.ts index 0317907e..2ef0b9e8 100644 --- a/apps/api/src/shared/bullmq-connection.ts +++ b/apps/api/src/shared/bullmq-connection.ts @@ -1,10 +1,13 @@ import type { Redis as IORedisConnection } from 'ioredis'; +import type { ConnectionOptions } from 'bullmq'; /** * BullMQ's connection type is pinned through a different ioredis version in the * dependency graph, so we centralize the compatibility cast here instead of * repeating it at every call site. */ -export function toBullMqConnection(connection: IORedisConnection): never { - return connection as never; +export function toBullMqConnection( + connection: IORedisConnection, +): ConnectionOptions { + return connection as unknown as ConnectionOptions; } From 9b2dc1eed2507c7be1255e823c68fa32ad527244 Mon Sep 17 00:00:00 2001 From: sergioballesteros-vd <221996264+sergioballesteros-vd@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:47:19 +0200 Subject: [PATCH 07/10] fix: ci de issue 223 --- apps/api/src/contexts/resources/domain/resource.spec.ts | 1 + .../drizzle/drizzle-resource.repository.int-spec.ts | 4 ++++ apps/api/src/shared/bullmq-connection.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/contexts/resources/domain/resource.spec.ts b/apps/api/src/contexts/resources/domain/resource.spec.ts index 69a765dd..3a33a9d2 100644 --- a/apps/api/src/contexts/resources/domain/resource.spec.ts +++ b/apps/api/src/contexts/resources/domain/resource.spec.ts @@ -77,6 +77,7 @@ describe('Resource', () => { quantity: 100, unit: 'litros', category: Category.Water, + supplyId: null, presentation: null, expiresAt: null, }); diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts index 9d9f6a9f..f06c458e 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts @@ -103,6 +103,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 200, unit: 'litros', category: Category.Water, + supplyId: null, presentation: null, expiresAt: '2026-07-01', }, @@ -111,6 +112,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 50, unit: null, category: Category.Shelter, + supplyId: null, presentation: null, expiresAt: null, }, @@ -127,6 +129,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, + supplyId: null, presentation: null, expiresAt: null, }, @@ -141,6 +144,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, + supplyId: null, presentation: null, expiresAt: null, }, diff --git a/apps/api/src/shared/bullmq-connection.ts b/apps/api/src/shared/bullmq-connection.ts index 2ef0b9e8..ef95313e 100644 --- a/apps/api/src/shared/bullmq-connection.ts +++ b/apps/api/src/shared/bullmq-connection.ts @@ -9,5 +9,5 @@ import type { ConnectionOptions } from 'bullmq'; export function toBullMqConnection( connection: IORedisConnection, ): ConnectionOptions { - return connection as unknown as ConnectionOptions; + return connection; } From 948de0409dd745868cd8cec8ed32ab235fe79174 Mon Sep 17 00:00:00 2001 From: sergioballesteros-vd <221996264+sergioballesteros-vd@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:25:54 +0200 Subject: [PATCH 08/10] chore: retrigger vercel deploy From eaad2a380acedb3b1f745ad3921aac24c6a37f52 Mon Sep 17 00:00:00 2001 From: Sergio Ballesteros Date: Tue, 30 Jun 2026 15:27:44 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(web):=20selector=20de=20insumo=20en?= =?UTF-8?q?=20oferta=20y=20l=C3=ADneas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/e/[slug]/donar/ofrecer/actions.ts | 7 + .../app/e/[slug]/donar/ofrecer/donar-form.tsx | 22 +- .../src/app/e/[slug]/peticion/items-field.tsx | 13 +- .../e/[slug]/registrar/inventory-field.tsx | 5 +- .../molecules/supply-line-row-fields.tsx | 13 +- .../components/molecules/supply-selector.tsx | 218 ++++++++++++++++++ apps/web/src/i18n/messages/en.ts | 6 +- apps/web/src/i18n/messages/es.ts | 6 +- 8 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/molecules/supply-selector.tsx diff --git a/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts b/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts index 0d589264..9bf4b452 100644 --- a/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts +++ b/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts @@ -38,6 +38,7 @@ export async function submitOffer( const rawDescription = formData.get('description'); const rawQuantity = formData.get('quantity'); const rawUnit = formData.get('unit'); + const rawSupplyId = formData.get('supplyId'); const rawAddress = formData.get('address'); const rawLatitude = formData.get('latitude'); const rawLongitude = formData.get('longitude'); @@ -88,6 +89,11 @@ export async function submitOffer( ? rawNotes.trim() : undefined; + const supplyId = + typeof rawSupplyId === 'string' && rawSupplyId.trim() !== '' + ? rawSupplyId.trim() + : undefined; + const donorOrganizationId = typeof rawOrgId === 'string' && rawOrgId.trim() !== '' ? rawOrgId.trim() @@ -112,6 +118,7 @@ export async function submitOffer( quantity: quantityRaw, category: rawCategory, ...(unit !== undefined ? { unit } : {}), + ...(supplyId !== undefined ? { supplyId } : {}), }, ], location: { address, latitude, longitude }, diff --git a/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx b/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx index 31851c4e..028ef0c0 100644 --- a/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx +++ b/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx @@ -14,6 +14,7 @@ import { DraftRestoredBanner } from '@/components/atoms/draft-restored-banner'; import { useFormDraft } from '@/lib/use-form-draft'; import { useLocale } from '@/i18n/locale-context'; import { MATERIAL_CATEGORIES, categoryLabel } from '@/lib/categories'; +import { SupplySelector } from '@/components/molecules/supply-selector'; import type { Messages } from '@/i18n/messages/es'; const INITIAL_STATE: OfferState = { status: 'idle' }; @@ -49,6 +50,7 @@ export function DonarForm({ const [category, setCategory] = useState(''); const [description, setDescription] = useState(''); + const [supplyId, setSupplyId] = useState(''); const [quantity, setQuantity] = useState(''); const [unit, setUnit] = useState(''); const [notes, setNotes] = useState(''); @@ -58,10 +60,11 @@ export function DonarForm({ ? `donar-${slug}-need-${targetNeedId}` : `donar-${slug}`; - const draftValues = { category, description, quantity, unit, notes }; + const draftValues = { category, description, supplyId, quantity, unit, notes }; const draftSetters = { category: setCategory, description: setDescription, + supplyId: setSupplyId, quantity: setQuantity, unit: setUnit, notes: setNotes, @@ -136,17 +139,20 @@ export function DonarForm({ htmlFor="description" label={<>{t.description_label} } > - setDescription(e.target.value)} + required + value={{ name: description, supplyId: supplyId === '' ? null : supplyId }} + onChange={(patch) => { + if (patch.name !== undefined) setDescription(patch.name); + if (patch.supplyId !== undefined) setSupplyId(patch.supplyId ?? ''); + }} /> + +
diff --git a/apps/web/src/app/e/[slug]/peticion/items-field.tsx b/apps/web/src/app/e/[slug]/peticion/items-field.tsx index aec136dd..8764dd73 100644 --- a/apps/web/src/app/e/[slug]/peticion/items-field.tsx +++ b/apps/web/src/app/e/[slug]/peticion/items-field.tsx @@ -13,6 +13,7 @@ import { ALL_CATEGORIES } from '@/lib/categories'; interface Item { id: number; name: string; + supplyId: string | null; quantity: number; unit: string; category: string; @@ -21,7 +22,14 @@ interface Item { let nextId = 1; function makeItem(): Item { - return { id: nextId++, name: '', quantity: 1, unit: '', category: 'food' }; + return { + id: nextId++, + name: '', + supplyId: null, + quantity: 1, + unit: '', + category: 'food', + }; } interface ItemsFieldProps { @@ -50,8 +58,9 @@ export function ItemsField({ t }: ItemsFieldProps) { }; const serialized = JSON.stringify( - items.map(({ name, quantity, unit, category }) => ({ + items.map(({ name, supplyId, quantity, unit, category }) => ({ name, + ...(supplyId !== null ? { supplyId } : {}), quantity, ...(unit.trim() !== '' ? { unit: unit.trim() } : {}), category, diff --git a/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx b/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx index d287e5aa..0ae16d06 100644 --- a/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx +++ b/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx @@ -10,6 +10,7 @@ import { MATERIAL_CATEGORIES } from '@/lib/categories'; interface Item { id: number; name: string; + supplyId: string | null; quantity: number; unit: string; category: string; @@ -22,6 +23,7 @@ function makeItem(): Item { return { id: nextId++, name: '', + supplyId: null, quantity: 1, unit: '', category: MATERIAL_CATEGORIES[0], @@ -105,8 +107,9 @@ export function InventoryField({ const serialized = JSON.stringify( items .filter((i) => i.name.trim() !== '') - .map(({ name, quantity, unit, category, expiresAt }) => ({ + .map(({ name, supplyId, quantity, unit, category, expiresAt }) => ({ name: name.trim(), + ...(supplyId !== null ? { supplyId } : {}), quantity, ...(unit.trim() !== '' ? { unit: unit.trim() } : {}), category, diff --git a/apps/web/src/components/molecules/supply-line-row-fields.tsx b/apps/web/src/components/molecules/supply-line-row-fields.tsx index 272b9b53..43faf783 100644 --- a/apps/web/src/components/molecules/supply-line-row-fields.tsx +++ b/apps/web/src/components/molecules/supply-line-row-fields.tsx @@ -1,9 +1,11 @@ 'use client'; import { categoryLabel } from '@/lib/categories'; +import { SupplySelector } from '@/components/molecules/supply-selector'; export interface SupplyLineRowValue { name: string; + supplyId: string | null; quantity: number; unit: string; category: string; @@ -91,14 +93,13 @@ export function SupplyLineRowFields({ > {labels.nameLabel} - onChange({ name: e.target.value })} + locale={locale} placeholder={labels.namePlaceholder} - className="w-full rounded-lg border-2 border-navy bg-white px-4 py-3 text-base text-ink placeholder:text-muted-soft focus:outline-none focus:ring-2 focus:ring-navy focus:ring-offset-2" + required={required} + value={{ name: value.name, supplyId: value.supplyId }} + onChange={(patch) => onChange(patch)} />
diff --git a/apps/web/src/components/molecules/supply-selector.tsx b/apps/web/src/components/molecules/supply-selector.tsx new file mode 100644 index 00000000..69d0035d --- /dev/null +++ b/apps/web/src/components/molecules/supply-selector.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Input } from '@/components/atoms/input'; +import type { Locale } from '@/i18n'; + +interface SupplyOption { + id: string; + code: string; + name: string; + categoryLabel: string; +} + +export interface SupplySelectorValue { + name: string; + supplyId: string | null; +} + +interface SupplySelectorProps { + id: string; + locale: Locale; + placeholder: string; + required?: boolean; + value: SupplySelectorValue; + onChange: (patch: Partial) => void; +} + +type Copy = { + other: string; + hint: string; + loading: string; + empty: string; + error: string; +}; + +const COPY: Record = { + es: { + other: 'Otro', + hint: 'Busca por nombre, alias o código.', + loading: 'Buscando insumos…', + empty: 'No hay coincidencias. Usa “Otro” si no está en el catálogo.', + error: 'No pudimos cargar sugerencias.', + }, + en: { + other: 'Other', + hint: 'Search by name, alias, or code.', + loading: 'Searching supplies…', + empty: 'No matches. Use “Other” if it is not in the catalog.', + error: 'We could not load suggestions.', + }, +}; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; +const DEBOUNCE_MS = 220; + +export function SupplySelector({ + id, + locale, + placeholder, + required = false, + value, + onChange, +}: SupplySelectorProps) { + const copy = COPY[locale]; + const [results, setResults] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const debounceRef = useRef | null>(null); + const containerRef = useRef(null); + const requestSeq = useRef(0); + + const fetchResults = useCallback( + async (term: string) => { + const trimmed = term.trim(); + if (trimmed.length < 2) { + setResults([]); + setError(false); + setLoading(false); + setIsOpen(false); + return; + } + + const seq = ++requestSeq.current; + setLoading(true); + setError(false); + try { + const params = new URLSearchParams({ + q: trimmed, + locale, + limit: '8', + }); + const response = await fetch(`${API_URL}/supplies?${params.toString()}`); + if (!response.ok) throw new Error('fetch failed'); + const data = (await response.json()) as SupplyOption[]; + if (seq !== requestSeq.current) return; + setResults(data); + setIsOpen(data.length > 0); + } catch { + if (seq !== requestSeq.current) return; + setResults([]); + setError(true); + setIsOpen(true); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, + [locale], + ); + + useEffect(() => { + const handler = (event: MouseEvent) => { + if ( + containerRef.current !== null && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + useEffect(() => { + return () => { + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + }; + }, []); + + function chooseSupply(supply: SupplyOption) { + onChange({ name: supply.name, supplyId: supply.id }); + setIsOpen(false); + setResults([]); + } + + function handleInputChange(next: string) { + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + setResults([]); + setError(false); + setLoading(false); + onChange({ name: next, supplyId: null }); + setIsOpen(next.trim().length >= 2); + debounceRef.current = setTimeout(() => { + void fetchResults(next); + }, DEBOUNCE_MS); + } + + function handleOther() { + onChange({ supplyId: null }); + setIsOpen(false); + } + + return ( +
+
+ handleInputChange(e.target.value)} + onFocus={() => setIsOpen(true)} + placeholder={placeholder} + role="combobox" + aria-autocomplete="list" + aria-expanded={isOpen} + aria-haspopup="listbox" + aria-controls={`${id}-listbox`} + className="flex-1" + /> + +
+ +

{copy.hint}

+ + {isOpen && value.name.trim().length >= 2 && ( +
+ {loading ? ( +

{copy.loading}

+ ) : error ? ( +

{copy.error}

+ ) : results.length === 0 ? ( +

{copy.empty}

+ ) : ( +
    + {results.map((supply) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/i18n/messages/en.ts b/apps/web/src/i18n/messages/en.ts index b9fe0b32..2d32cd2c 100644 --- a/apps/web/src/i18n/messages/en.ts +++ b/apps/web/src/i18n/messages/en.ts @@ -526,11 +526,11 @@ export const en = { choose_deliver_subtitle: 'Choose the point and pre-register your delivery — you get a code/QR for the desk', choose_offer_title: 'Offer supplies', - choose_offer_subtitle: 'Describe it and the coordination team handles it', + choose_offer_subtitle: 'Search the supply and complete the offer', directed_offer_label: 'Offering for:', category_label: 'Supply category', - description_label: 'Supply description', + description_label: 'Supply / item', description_placeholder: 'e.g. 25 kg rice bags', quantity_label: 'Quantity', quantity_placeholder: '50', @@ -551,7 +551,7 @@ export const en = { // server-action messages err_invalid_category: 'Invalid category.', - err_description_too_short: 'Describe the item (at least 2 characters).', + err_description_too_short: 'Specify the supply or item (at least 2 characters).', err_invalid_quantity: 'Quantity must be a positive whole number.', err_location_required: 'Select a location.', err_submit_failed: 'Couldn’t submit the offer. Please try again.', diff --git a/apps/web/src/i18n/messages/es.ts b/apps/web/src/i18n/messages/es.ts index cf9e91cb..1d36f2ab 100644 --- a/apps/web/src/i18n/messages/es.ts +++ b/apps/web/src/i18n/messages/es.ts @@ -550,11 +550,11 @@ export const es = { 'Elige el punto y pre-registra tu entrega — obtienes un código/QR para el mostrador', choose_offer_title: 'Ofrecer material', choose_offer_subtitle: - 'Lo describes y el equipo de coordinación lo gestiona', + 'Busca el insumo y completa la oferta', directed_offer_label: 'Ofreces para:', category_label: 'Categoría del material', - description_label: 'Descripción del material', + description_label: 'Insumo / material', description_placeholder: 'Ej. Sacos de arroz de 25 kg', quantity_label: 'Cantidad', quantity_placeholder: '50', @@ -575,7 +575,7 @@ export const es = { // server-action messages err_invalid_category: 'Categoría no válida.', - err_description_too_short: 'Describe el material (al menos 2 caracteres).', + err_description_too_short: 'Indica el insumo o material (al menos 2 caracteres).', err_invalid_quantity: 'La cantidad debe ser un número entero positivo.', err_location_required: 'Selecciona una ubicación.', err_submit_failed: 'Error al enviar la oferta. Inténtalo de nuevo.', From 642cdd9eee267f197b6f33ae13a16038dcadc239 Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 30 Jun 2026 21:27:40 +0200 Subject: [PATCH 10/10] test(supplies): add integration tests for supplyId database roundtrip across contexts --- .../drizzle-need.repository.int-spec.ts | 18 ++ ...zle-donation-intake.repository.int-spec.ts | 18 ++ .../drizzle-offer.repository.int-spec.ts | 18 ++ .../drizzle-resource.repository.int-spec.ts | 29 +++- .../application/list-supplies.spec.ts | 36 ++++ .../supplies/application/list-supplies.ts | 160 +++++++++++++----- .../components/molecules/supply-selector.tsx | 90 ++++++++-- 7 files changed, 316 insertions(+), 53 deletions(-) diff --git a/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts b/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts index 0147bdab..937031ea 100644 --- a/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts +++ b/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts @@ -1,6 +1,7 @@ import { eq } from 'drizzle-orm'; import { createDb, Db } from '../../../../shared/db'; import { needsTable, needItemsTable } from './schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { DrizzleNeedRepository } from './drizzle-need.repository'; import { Need } from '../../domain/need'; import { NeedId } from '../../domain/need-id'; @@ -31,6 +32,7 @@ function makeItems() { quantity: 50, unit: 'liters', category: Category.Water, + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }), ]; } @@ -66,6 +68,19 @@ describe('DrizzleNeedRepository (integration)', () => { beforeEach(async () => { await db.delete(needItemsTable); await db.delete(needsTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips a need through Postgres (with items and location)', async () => { @@ -90,6 +105,9 @@ describe('DrizzleNeedRepository (integration)', () => { expect(found!.items[0].quantity).toBe(50); expect(found!.items[0].unit).toBe('liters'); expect(found!.items[0].category).toBe(Category.Water); + expect(found!.items[0].supplyId).toBe( + '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + ); }); it('round-trips the resourceId link to a final recipient (#60)', async () => { diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts index 6742265e..2d66625f 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts @@ -3,6 +3,7 @@ import { donationIntakeLinesTable, donationIntakesTable, } from './donation-intake-schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { DrizzleDonationIntakeRepository } from './drizzle-donation-intake.repository'; import { DonationIntake } from '../../domain/donation-intake'; import { DonationIntakeId } from '../../domain/donation-intake-id'; @@ -39,6 +40,7 @@ function makeIntake(code: string) { unit: 'sacos', presentation: null, expiresAt: '2026-07-01', + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }, }, ], @@ -62,6 +64,19 @@ describe('DrizzleDonationIntakeRepository (integration)', () => { beforeEach(async () => { await db.delete(donationIntakeLinesTable); await db.delete(donationIntakesTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips an intake with lines through Postgres', async () => { @@ -75,6 +90,9 @@ describe('DrizzleDonationIntakeRepository (integration)', () => { expect(found.lines).toHaveLength(1); expect(found.lines[0]?.supplyLine.name).toBe('Harina'); expect(found.lines[0]?.supplyLine.expiresAt).toBe('2026-07-01'); + expect(found.lines[0]?.supplyLine.supplyId).toBe( + '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + ); }); it('updates lines on save (replace)', async () => { diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts index 80ca3544..7df025ed 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts @@ -1,5 +1,6 @@ import { createDb, Db } from '../../../../shared/db'; import { offersTable } from './schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { DrizzleOfferRepository } from './drizzle-offer.repository'; import { DonationOffer } from '../../domain/donation-offer'; import { OfferId } from '../../domain/offer-id'; @@ -37,6 +38,7 @@ function makeOffer(overrides?: { category?: Category; name?: string }) { unit: 'bags', category: overrides?.category ?? Category.Food, presentation: null, + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }), ], location: makeLocation(), @@ -62,6 +64,19 @@ describe('DrizzleOfferRepository (integration)', () => { beforeEach(async () => { // FK cascade removes offer_items with their offers. await db.delete(offersTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips an offer (with its lines) through Postgres', async () => { @@ -78,6 +93,9 @@ describe('DrizzleOfferRepository (integration)', () => { expect(found!.items[0].name).toBe('Rice bags'); expect(found!.items[0].quantity).toBe(25); expect(found!.items[0].unit).toBe('bags'); + expect(found!.items[0].supplyId).toBe( + '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + ); expect(found!.location.address).toBe('Test St, Caracas'); expect(found!.location.latitude).toBe(10.4806); expect(found!.targetNeedId).toBeNull(); diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts index f06c458e..fd19e64b 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts @@ -1,6 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { createDb, Db } from '../../../../shared/db'; import { resourcesTable } from './schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { emergenciesTable } from '../../../emergencies/infrastructure/drizzle/schema'; import { DrizzleResourceRepository } from './drizzle-resource.repository'; import { Resource } from '../../domain/resource'; @@ -43,6 +44,27 @@ describe('DrizzleResourceRepository (integration)', () => { }); beforeEach(async () => { await db.delete(resourcesTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d9', + code: 'TEST-0002', + name: 'Arroz Test', + categorySlug: 'food', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips an aggregate through Postgres', async () => { @@ -82,6 +104,7 @@ describe('DrizzleResourceRepository (integration)', () => { unit: 'litros', category: Category.Water, expiresAt: '2026-07-01', + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }), SupplyLine.create({ name: 'Mantas', @@ -103,7 +126,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 200, unit: 'litros', category: Category.Water, - supplyId: null, + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', presentation: null, expiresAt: '2026-07-01', }, @@ -129,7 +152,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, - supplyId: null, + supplyId: '2e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d9', presentation: null, expiresAt: null, }, @@ -144,7 +167,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, - supplyId: null, + supplyId: '2e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d9', presentation: null, expiresAt: null, }, diff --git a/apps/api/src/contexts/supplies/application/list-supplies.spec.ts b/apps/api/src/contexts/supplies/application/list-supplies.spec.ts index 555b1c04..de67ecad 100644 --- a/apps/api/src/contexts/supplies/application/list-supplies.spec.ts +++ b/apps/api/src/contexts/supplies/application/list-supplies.spec.ts @@ -62,4 +62,40 @@ describe('ListSupplies', () => { expect(result).toHaveLength(1); expect(result[0]?.id).toBe(catalog[0].id); }); + + it('soporta búsquedas difusas (fuzzy) con errores tipográficos (ej: abua -> agua)', async () => { + const result = await new ListSupplies(readModel()).execute({ + q: 'abua', + locale: 'es', + limit: 20, + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.nameEs).toBe('Agua potable'); + }); + + it('soporta búsquedas por subcadenas o partes de palabra (ej: gua -> agua)', async () => { + const result = await new ListSupplies(readModel()).execute({ + q: 'gua', + locale: 'es', + limit: 20, + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.nameEs).toBe('Agua potable'); + }); + + it('ordena los resultados de forma que los mejores matches (exacto/prefijo) salgan primero', async () => { + const result = await new ListSupplies(readModel()).execute({ + q: 'agua', + locale: 'es', + limit: 20, + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.nameEs).toBe('Agua potable'); + }); }); diff --git a/apps/api/src/contexts/supplies/application/list-supplies.ts b/apps/api/src/contexts/supplies/application/list-supplies.ts index 4c63c844..6f2ffbe9 100644 --- a/apps/api/src/contexts/supplies/application/list-supplies.ts +++ b/apps/api/src/contexts/supplies/application/list-supplies.ts @@ -15,6 +15,82 @@ export interface SupplyCatalogQuery { offset: number; } +function levenshtein(a: string, b: string): number { + const tmp: number[][] = []; + for (let i = 0; i <= a.length; i++) { + tmp[i] = [i]; + } + for (let j = 0; j <= b.length; j++) { + tmp[0][j] = j; + } + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + tmp[i][j] = Math.min( + tmp[i - 1][j] + 1, // deletion + tmp[i][j - 1] + 1, // insertion + tmp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), // substitution + ); + } + } + return tmp[a.length][b.length]; +} + +function getMatchScore( + record: PublicSupplyRecord, + query: string, + exactMatchId: string | null, +): number { + if (exactMatchId && record.id === exactMatchId) { + return 100; + } + + const queryWords = query.toLowerCase().split(/\s+/).filter(Boolean); + if (queryWords.length === 0) return 0; + + const nameEsWords = record.nameEs.toLowerCase().split(/\s+/).filter(Boolean); + const nameEnWords = (record.nameEn ?? '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + const aliasWords = record.aliases.flatMap((a) => + a.toLowerCase().split(/\s+/).filter(Boolean), + ); + + let score = 0; + + for (const qWord of queryWords) { + let bestWordScore = 0; + + const check = (tWord: string, weight: number) => { + if (tWord === qWord) { + bestWordScore = Math.max(bestWordScore, 10 * weight); + } else if (tWord.startsWith(qWord)) { + bestWordScore = Math.max(bestWordScore, 8 * weight); + } else if (tWord.includes(qWord)) { + bestWordScore = Math.max(bestWordScore, 5 * weight); + } else if (qWord.length >= 3) { + const dist = levenshtein(qWord, tWord); + const maxAllowed = qWord.length > 5 ? 2 : 1; + if (dist <= maxAllowed) { + bestWordScore = Math.max(bestWordScore, (5 - dist) * weight); + } + } + }; + + for (const tWord of nameEsWords) check(tWord, 2); + for (const tWord of nameEnWords) check(tWord, 2); + for (const tWord of aliasWords) check(tWord, 1.5); + + if (bestWordScore === 0) { + // If a query word matches absolutely nothing, the whole query fails + return 0; + } + score += bestWordScore; + } + + return score; +} + /** * Índice de resolución exacta (nombre canónico es/en, código y alias). Los * registros ya son `active`, así que los campos de gestión del agregado se @@ -55,56 +131,60 @@ export class ListSupplies { async execute(query: SupplyCatalogQuery): Promise { const records = await this.catalog.listActive(); const resolvedLocale = query.locale === 'en' ? 'en' : 'es'; - // El resolver (índice de match exacto por nombre/código/alias) solo hace - // falta cuando hay término de búsqueda; evitamos construirlo —O(N) objetos— - // en listados por categoría o sin filtro. const normalizedQuery = query.q ? normalizeSupplyText(query.q) : ''; const exactMatchId = query.q ? toSupplyResolver(records).resolve(query.q) : null; - const filtered = records.filter((record) => { - if (query.categorySlug && record.categorySlug !== query.categorySlug) { - return false; - } - if (!normalizedQuery) { - return true; - } - if (exactMatchId && record.id === exactMatchId) { - return true; - } - const searchable = normalizeSupplyText( - [ - record.code, - record.nameEs, - record.nameEn ?? '', - record.categorySlug, - record.categoryLabelEs, - record.categoryLabelEn ?? '', - ...record.aliases, - ].join(' '), + if (!normalizedQuery) { + const filtered = records.filter((record) => { + return ( + !query.categorySlug || record.categorySlug === query.categorySlug + ); + }); + const collator = new Intl.Collator(resolvedLocale, { + sensitivity: 'base', + }); + const sorted = [...filtered].sort((a, b) => { + const aLabel = + resolvedLocale === 'en' && a.nameEn ? a.nameEn : a.nameEs; + const bLabel = + resolvedLocale === 'en' && b.nameEn ? b.nameEn : b.nameEs; + return collator.compare(aLabel, bLabel); + }); + return sorted.slice(query.offset, query.offset + query.limit); + } + + const scored = records + .map((record) => ({ + record, + score: getMatchScore(record, normalizedQuery, exactMatchId), + })) + .filter( + (item) => + item.score > 0 && + (!query.categorySlug || + item.record.categorySlug === query.categorySlug), ); - return searchable.includes(normalizedQuery); - }); const collator = new Intl.Collator(resolvedLocale, { sensitivity: 'base' }); - const sorted = [...filtered].sort((a, b) => { - if (exactMatchId) { - const aExact = a.id === exactMatchId; - const bExact = b.id === exactMatchId; - if (aExact !== bExact) { - return aExact ? -1 : 1; - } - } - const aLabel = resolvedLocale === 'en' && a.nameEn ? a.nameEn : a.nameEs; - const bLabel = resolvedLocale === 'en' && b.nameEn ? b.nameEn : b.nameEs; - const labelCompare = collator.compare(aLabel, bLabel); - if (labelCompare !== 0) { - return labelCompare; + const sorted = scored.sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; } - return a.code.localeCompare(b.code, 'en'); + const aLabel = + resolvedLocale === 'en' && a.record.nameEn + ? a.record.nameEn + : a.record.nameEs; + const bLabel = + resolvedLocale === 'en' && b.record.nameEn + ? b.record.nameEn + : b.record.nameEs; + return collator.compare(aLabel, bLabel); }); - return sorted.slice(query.offset, query.offset + query.limit); + return sorted + .map((item) => item.record) + .slice(query.offset, query.offset + query.limit); } } diff --git a/apps/web/src/components/molecules/supply-selector.tsx b/apps/web/src/components/molecules/supply-selector.tsx index 69d0035d..87fb3582 100644 --- a/apps/web/src/components/molecules/supply-selector.tsx +++ b/apps/web/src/components/molecules/supply-selector.tsx @@ -53,6 +53,34 @@ const COPY: Record = { const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; const DEBOUNCE_MS = 220; +const CheckIcon = ( + + + +); + +const SearchIcon = ( + + + +); + export function SupplySelector({ id, locale, @@ -66,6 +94,7 @@ export function SupplySelector({ const [isOpen, setIsOpen] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); const debounceRef = useRef | null>(null); const containerRef = useRef(null); const requestSeq = useRef(0); @@ -78,6 +107,7 @@ export function SupplySelector({ setError(false); setLoading(false); setIsOpen(false); + setFocusedIndex(-1); return; } @@ -96,11 +126,13 @@ export function SupplySelector({ if (seq !== requestSeq.current) return; setResults(data); setIsOpen(data.length > 0); + setFocusedIndex(-1); } catch { if (seq !== requestSeq.current) return; setResults([]); setError(true); setIsOpen(true); + setFocusedIndex(-1); } finally { if (seq === requestSeq.current) setLoading(false); } @@ -127,17 +159,19 @@ export function SupplySelector({ }; }, []); - function chooseSupply(supply: SupplyOption) { + const chooseSupply = useCallback((supply: SupplyOption) => { onChange({ name: supply.name, supplyId: supply.id }); setIsOpen(false); setResults([]); - } + setFocusedIndex(-1); + }, [onChange]); function handleInputChange(next: string) { if (debounceRef.current !== null) clearTimeout(debounceRef.current); setResults([]); setError(false); setLoading(false); + setFocusedIndex(-1); onChange({ name: next, supplyId: null }); setIsOpen(next.trim().length >= 2); debounceRef.current = setTimeout(() => { @@ -148,10 +182,37 @@ export function SupplySelector({ function handleOther() { onChange({ supplyId: null }); setIsOpen(false); + setFocusedIndex(-1); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (!isOpen) { + if (e.key === 'ArrowDown' && value.name.trim().length >= 2) { + setIsOpen(true); + void fetchResults(value.name); + } + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedIndex((prev) => (prev + 1 < results.length ? prev + 1 : prev)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : prev)); + } else if (e.key === 'Enter') { + if (focusedIndex >= 0 && focusedIndex < results.length) { + e.preventDefault(); + chooseSupply(results[focusedIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsOpen(false); + } } return ( -
+
handleInputChange(e.target.value)} - onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + onFocus={() => setIsOpen(value.name.trim().length >= 2)} placeholder={placeholder} role="combobox" aria-autocomplete="list" aria-expanded={isOpen} aria-haspopup="listbox" aria-controls={`${id}-listbox`} - className="flex-1" + icon={value.supplyId ? CheckIcon : SearchIcon} + className={`flex-1 ${ + value.supplyId + ? 'border-emerald-500 bg-emerald-50/10 focus:border-emerald-500 focus:ring-emerald-500/30' + : '' + }`} />