From 45dcfb07cbcafba5a58855180b7946148e6b8e31 Mon Sep 17 00:00:00 2001 From: Victor Date: Sun, 26 Jul 2026 08:50:39 +0200 Subject: [PATCH] feat(resources): estados operativos preparing y assisted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade dos estados a PublicStatus, públicamente visibles y establecibles vía POST /resources/:id/status: - preparing ("En preparación"): centro que se está habilitando, aún no operativo pero visible en el mapa para anticipar su apertura. - assisted ("Asistido"): alojamiento para colectivos específicos. Sin migración (public_status es columna text). Incluye visibilidad pública (isPubliclyVisible, PUBLICLY_VISIBLE_STATUSES, get-public-resource), DTO de cambio de estado, statusMap del controller, métricas (init del Record y total robusto con Object.values) y el semáforo web (status-light, i18n es/en, resource-detail, admin statusLabel, emergency-map). api-client regenerado. --- apps/api/openapi.json | 28 ++++++++++++++----- .../application/get-emergency-metrics.ts | 11 ++++---- .../drizzle/drizzle-metrics-reader.ts | 2 ++ .../application/get-public-resource.ts | 2 ++ .../resources/domain/resource-enums.ts | 4 +++ .../resources/domain/resource.spec.ts | 14 ++++++++++ .../src/contexts/resources/domain/resource.ts | 4 ++- .../drizzle/drizzle-resource.repository.ts | 4 +++ .../resources/infrastructure/http/dto.ts | 7 +++-- .../http/resources.controller.ts | 2 ++ .../in-memory-resource.repository.ts | 2 ++ .../app/admin/points/centro-presentation.ts | 12 +++++++- .../web/src/components/atoms/status-light.tsx | 4 +++ .../components/organisms/emergency-map.tsx | 9 +++++- .../components/organisms/resource-detail.tsx | 2 ++ apps/web/src/i18n/messages/en.ts | 2 ++ apps/web/src/i18n/messages/es.ts | 2 ++ packages/api-client/src/schema.ts | 14 +++++----- 18 files changed, 99 insertions(+), 26 deletions(-) diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5142d2a6..17a8796d 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2299,7 +2299,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ] } }, @@ -10491,7 +10493,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ], "example": "saturated", "description": "Target operational status. Hidden is not allowed; use close() to deactivate." @@ -10617,7 +10621,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ], "example": "active" }, @@ -11021,7 +11027,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ], "example": "active" }, @@ -11271,7 +11279,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ], "example": "active" }, @@ -11506,7 +11516,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ], "example": "active" }, @@ -11746,7 +11758,9 @@ "active", "saturated", "paused", - "closed" + "closed", + "preparing", + "assisted" ], "example": "active" }, diff --git a/apps/api/src/contexts/metrics/application/get-emergency-metrics.ts b/apps/api/src/contexts/metrics/application/get-emergency-metrics.ts index 1e863310..5a23ce68 100644 --- a/apps/api/src/contexts/metrics/application/get-emergency-metrics.ts +++ b/apps/api/src/contexts/metrics/application/get-emergency-metrics.ts @@ -55,12 +55,11 @@ export class GetEmergencyMetrics { needCounts[NeedStatus.Pending] + needCounts[NeedStatus.Validated]; const needClosed = needCounts[NeedStatus.Fulfilled]; - const resourceTotal = - resourceCounts[PublicStatus.Hidden] + - resourceCounts[PublicStatus.Active] + - resourceCounts[PublicStatus.Saturated] + - resourceCounts[PublicStatus.Paused] + - resourceCounts[PublicStatus.Closed]; + // Sum across every public status (robust to new statuses being added). + const resourceTotal = Object.values(resourceCounts).reduce( + (sum, n) => sum + (n ?? 0), + 0, + ); return { needs: { diff --git a/apps/api/src/contexts/metrics/infrastructure/drizzle/drizzle-metrics-reader.ts b/apps/api/src/contexts/metrics/infrastructure/drizzle/drizzle-metrics-reader.ts index 1e5c0833..dc955c05 100644 --- a/apps/api/src/contexts/metrics/infrastructure/drizzle/drizzle-metrics-reader.ts +++ b/apps/api/src/contexts/metrics/infrastructure/drizzle/drizzle-metrics-reader.ts @@ -50,6 +50,8 @@ export class DrizzleMetricsReader implements MetricsReader { [PublicStatus.Saturated]: 0, [PublicStatus.Paused]: 0, [PublicStatus.Closed]: 0, + [PublicStatus.Preparing]: 0, + [PublicStatus.Assisted]: 0, }; for (const row of rows) { const status = row.publicStatus as PublicStatus; diff --git a/apps/api/src/contexts/resources/application/get-public-resource.ts b/apps/api/src/contexts/resources/application/get-public-resource.ts index aa52da05..f97ef717 100644 --- a/apps/api/src/contexts/resources/application/get-public-resource.ts +++ b/apps/api/src/contexts/resources/application/get-public-resource.ts @@ -7,6 +7,8 @@ const VISIBLE = [ PublicStatus.Active, PublicStatus.Saturated, PublicStatus.Paused, + PublicStatus.Preparing, + PublicStatus.Assisted, ]; const PUBLICLY_VISIBLE_VERIFICATION = [ diff --git a/apps/api/src/contexts/resources/domain/resource-enums.ts b/apps/api/src/contexts/resources/domain/resource-enums.ts index 16826869..8b32daf0 100644 --- a/apps/api/src/contexts/resources/domain/resource-enums.ts +++ b/apps/api/src/contexts/resources/domain/resource-enums.ts @@ -22,4 +22,8 @@ export enum PublicStatus { Saturated = 'saturated', Paused = 'paused', Closed = 'closed', + /** En preparación: aún no operativo, pero públicamente visible. */ + Preparing = 'preparing', + /** Alojamiento asistido / colectivo específico; públicamente visible. */ + Assisted = 'assisted', } diff --git a/apps/api/src/contexts/resources/domain/resource.spec.ts b/apps/api/src/contexts/resources/domain/resource.spec.ts index d26e51bc..1d243d66 100644 --- a/apps/api/src/contexts/resources/domain/resource.spec.ts +++ b/apps/api/src/contexts/resources/domain/resource.spec.ts @@ -193,6 +193,20 @@ describe('Resource', () => { expect(r.publicStatus).toBe(PublicStatus.Paused); }); + it('transitions Active → Preparing and stays publicly visible', () => { + const r = makePublished(); + r.changePublicStatus(PublicStatus.Preparing); + expect(r.publicStatus).toBe(PublicStatus.Preparing); + expect(r.isPubliclyVisible()).toBe(true); + }); + + it('transitions Active → Assisted and stays publicly visible', () => { + const r = makePublished(); + r.changePublicStatus(PublicStatus.Assisted); + expect(r.publicStatus).toBe(PublicStatus.Assisted); + expect(r.isPubliclyVisible()).toBe(true); + }); + it('throws ResourceNotPublishedError when source is Hidden', () => { const r = make(); expect(() => r.changePublicStatus(PublicStatus.Active)).toThrow( diff --git a/apps/api/src/contexts/resources/domain/resource.ts b/apps/api/src/contexts/resources/domain/resource.ts index a5648473..5591afac 100644 --- a/apps/api/src/contexts/resources/domain/resource.ts +++ b/apps/api/src/contexts/resources/domain/resource.ts @@ -445,7 +445,9 @@ export class Resource { return ( this._publicStatus === PublicStatus.Active || this._publicStatus === PublicStatus.Saturated || - this._publicStatus === PublicStatus.Paused + this._publicStatus === PublicStatus.Paused || + this._publicStatus === PublicStatus.Preparing || + this._publicStatus === PublicStatus.Assisted ); } diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts index 106b925a..33fac180 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts @@ -34,6 +34,8 @@ const PUBLICLY_VISIBLE_STATUSES = [ PublicStatus.Active, PublicStatus.Saturated, PublicStatus.Paused, + PublicStatus.Preparing, + PublicStatus.Assisted, ]; /** @@ -823,6 +825,8 @@ export class DrizzleResourceRepository implements ResourceRepository { [PublicStatus.Saturated]: 0, [PublicStatus.Paused]: 0, [PublicStatus.Closed]: 0, + [PublicStatus.Preparing]: 0, + [PublicStatus.Assisted]: 0, }; for (const row of rows) { const status = row.publicStatus as PublicStatus; diff --git a/apps/api/src/contexts/resources/infrastructure/http/dto.ts b/apps/api/src/contexts/resources/infrastructure/http/dto.ts index de15cc81..1bf7012f 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/dto.ts @@ -246,13 +246,14 @@ export class UpdateInventoryDto { export class UpdateResourcePublicStatusDto { @ApiProperty({ - enum: ['active', 'saturated', 'paused', 'closed'], + enum: ['active', 'saturated', 'paused', 'closed', 'preparing', 'assisted'], example: 'saturated', description: 'Target operational status. Hidden is not allowed; use close() to deactivate.', }) - @IsEnum(['active', 'saturated', 'paused', 'closed']) - status!: 'active' | 'saturated' | 'paused' | 'closed'; + @IsEnum(['active', 'saturated', 'paused', 'closed', 'preparing', 'assisted']) + status!: + 'active' | 'saturated' | 'paused' | 'closed' | 'preparing' | 'assisted'; } export class NearbyResourcesQueryDto { 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 a91f46ad..f0feb30d 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts @@ -438,6 +438,8 @@ export class ResourcesController { saturated: PublicStatus.Saturated, paused: PublicStatus.Paused, closed: PublicStatus.Closed, + preparing: PublicStatus.Preparing, + assisted: PublicStatus.Assisted, }; await this.updateStatus.execute({ resourceId, diff --git a/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.ts b/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.ts index 88032c15..4d3c52d1 100644 --- a/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.ts +++ b/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.ts @@ -125,6 +125,8 @@ export class InMemoryResourceRepository implements ResourceRepository { [PublicStatus.Saturated]: 0, [PublicStatus.Paused]: 0, [PublicStatus.Closed]: 0, + [PublicStatus.Preparing]: 0, + [PublicStatus.Assisted]: 0, }; for (const snap of this.store.values()) { if (snap.emergencyId === emergencyId.value) { diff --git a/apps/web/src/app/admin/points/centro-presentation.ts b/apps/web/src/app/admin/points/centro-presentation.ts index a80abe60..6e8f42be 100644 --- a/apps/web/src/app/admin/points/centro-presentation.ts +++ b/apps/web/src/app/admin/points/centro-presentation.ts @@ -35,9 +35,19 @@ const STATUS_KEYS: Record = { closed: 'centros_status_closed', }; +/** + * Operational states not (yet) present in the admin i18n bundle. Rendered with + * direct labels so the admin console shows them correctly meanwhile. + */ +const STATUS_LITERALS: Record = { + preparing: 'En preparación', + assisted: 'Asistido', +}; + export function statusLabel(status: string, ta: AdminMessages): string { const key = STATUS_KEYS[status]; - return key ? ta[key] : status; + if (key) return ta[key]; + return STATUS_LITERALS[status] ?? status; } export const PUBLIC_STATUSES: ReadonlyArray = [ diff --git a/apps/web/src/components/atoms/status-light.tsx b/apps/web/src/components/atoms/status-light.tsx index 234192db..e5e640eb 100644 --- a/apps/web/src/components/atoms/status-light.tsx +++ b/apps/web/src/components/atoms/status-light.tsx @@ -16,6 +16,8 @@ const COLOR_MAP: Record = { paused: 'bg-accent', closed: 'bg-danger', hidden: 'bg-muted', + preparing: 'bg-accent', + assisted: 'bg-muted', }; const LABEL_KEY: Record = { @@ -24,6 +26,8 @@ const LABEL_KEY: Record = { paused: 'paused', closed: 'closed', hidden: 'hidden', + preparing: 'preparing', + assisted: 'assisted', }; // `t` is optional — falls back to Spanish when omitted (used in coordinator pages). diff --git a/apps/web/src/components/organisms/emergency-map.tsx b/apps/web/src/components/organisms/emergency-map.tsx index e995f6f3..643708f5 100644 --- a/apps/web/src/components/organisms/emergency-map.tsx +++ b/apps/web/src/components/organisms/emergency-map.tsx @@ -41,7 +41,14 @@ const ICONS: Record = { }; // ── Types ───────────────────────────────────────────────────────────────────── -export type ResourcePublicStatus = 'active' | 'saturated' | 'paused' | 'closed' | 'hidden'; +export type ResourcePublicStatus = + | 'active' + | 'saturated' + | 'paused' + | 'closed' + | 'hidden' + | 'preparing' + | 'assisted'; export interface MapPoint { id: string; diff --git a/apps/web/src/components/organisms/resource-detail.tsx b/apps/web/src/components/organisms/resource-detail.tsx index 3cfdaef3..0700c98e 100644 --- a/apps/web/src/components/organisms/resource-detail.tsx +++ b/apps/web/src/components/organisms/resource-detail.tsx @@ -60,6 +60,8 @@ export function ResourceDetail({ saturated: tc.public_status_saturated, paused: tc.public_status_paused, closed: tc.public_status_closed, + preparing: 'En preparación', + assisted: 'Asistido', }; const [state, formAction, pending] = useActionState( diff --git a/apps/web/src/i18n/messages/en.ts b/apps/web/src/i18n/messages/en.ts index fc732a11..7406f2e3 100644 --- a/apps/web/src/i18n/messages/en.ts +++ b/apps/web/src/i18n/messages/en.ts @@ -565,6 +565,8 @@ export const en = { paused: 'Paused', closed: 'Closed', hidden: 'Hidden', + preparing: 'Preparing', + assisted: 'Assisted', aria_prefix: 'Operational status:', }, diff --git a/apps/web/src/i18n/messages/es.ts b/apps/web/src/i18n/messages/es.ts index 60c8a3c3..51395dbf 100644 --- a/apps/web/src/i18n/messages/es.ts +++ b/apps/web/src/i18n/messages/es.ts @@ -591,6 +591,8 @@ export const es = { paused: 'En pausa', closed: 'Cerrado', hidden: 'Oculto', + preparing: 'En preparación', + assisted: 'Asistido', aria_prefix: 'Estado operativo:', }, diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index b1dc7d7c..c80a602c 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -3370,7 +3370,7 @@ export interface components { * @example saturated * @enum {string} */ - status: "active" | "saturated" | "paused" | "closed"; + status: "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; }; MyManagedResourceDto: { /** @@ -3426,7 +3426,7 @@ export interface components { * @example active * @enum {string} */ - publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed"; + publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; /** Format: uuid */ ownerOrganizationId: string | null; /** @@ -3624,7 +3624,7 @@ export interface components { * @example active * @enum {string} */ - publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed"; + publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; /** Format: uuid */ ownerOrganizationId: string | null; /** @@ -3758,7 +3758,7 @@ export interface components { * @example active * @enum {string} */ - publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed"; + publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; /** Format: uuid */ ownerOrganizationId: string | null; /** @@ -3883,7 +3883,7 @@ export interface components { * @example active * @enum {string} */ - publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed"; + publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; /** Format: uuid */ ownerOrganizationId: string | null; /** @@ -4009,7 +4009,7 @@ export interface components { * @example active * @enum {string} */ - publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed"; + publicStatus: "hidden" | "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; /** Format: uuid */ ownerOrganizationId: string | null; /** @@ -8139,7 +8139,7 @@ export interface operations { /** @description Filter by resource type */ type?: "collection_point" | "delivery_point" | "collection_and_delivery" | "warehouse" | "transport" | "supplier" | "venue"; /** @description Filter by operational status (includes hidden/closed — admin only) */ - status?: "hidden" | "active" | "saturated" | "paused" | "closed"; + status?: "hidden" | "active" | "saturated" | "paused" | "closed" | "preparing" | "assisted"; /** @description Filter by verification level */ verification?: "unverified" | "verified" | "official" | "rejected"; /** @description Full-text search matched against name, address and city (case-insensitive, max 100 chars) */