diff --git a/apps/api/drizzle/0044_resource_dispute_cooldown.sql b/apps/api/drizzle/0044_resource_dispute_cooldown.sql new file mode 100644 index 00000000..21b716ee --- /dev/null +++ b/apps/api/drizzle/0044_resource_dispute_cooldown.sql @@ -0,0 +1,3 @@ +ALTER TABLE resources + ADD COLUMN IF NOT EXISTS dispute_dismissed_at timestamptz; +--> statement-breakpoint diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5123eb3a..3ada40ad 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -42,6 +42,12 @@ const isTestEnv = process.env.NODE_ENV === 'test'; ttl: 60_000, limit: 5, }, + { + // validity reports: 20 per hour per IP (anti-abuse) + name: 'validity', + ttl: 3_600_000, + limit: 20, + }, ], ), DatabaseModule, diff --git a/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts b/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts index 703aaabd..1b4acfce 100644 --- a/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts +++ b/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts @@ -58,8 +58,11 @@ describe('ReportResourceValidity', () => { return id; } - const useCase = (threshold?: number): ReportResourceValidity => - new ReportResourceValidity(resources, reports, bus, threshold); + const useCase = ( + threshold?: number, + cooldownMs?: number, + ): ReportResourceValidity => + new ReportResourceValidity(resources, reports, bus, threshold, cooldownMs); const cmd = (resourceId: string, reporterUserId: string) => ({ resourceId, @@ -175,4 +178,22 @@ describe('ReportResourceValidity', () => { ResourceNotReportableError, ); }); + + it('cooldown activo tras dismiss: el umbral se alcanza pero no marca disputed', async () => { + const id = await seedPublished(); + const rep = useCase(2); + await rep.execute(cmd(id, 'user-1')); + await rep.execute(cmd(id, 'user-2')); + let r = await resources.findById(ResourceId.fromString(id)); + expect(r!.disputed).toBe(true); + + r!.clearDispute('dismiss'); + await resources.save(r!); + + const repWithCooldown = useCase(1, 86_400_000); + const result = await repWithCooldown.execute(cmd(id, 'user-3')); + expect(result.disputed).toBe(false); + r = await resources.findById(ResourceId.fromString(id)); + expect(r!.disputed).toBe(false); + }); }); diff --git a/apps/api/src/contexts/resources/application/report-resource-validity.ts b/apps/api/src/contexts/resources/application/report-resource-validity.ts index 19d6dca4..1e1d0c86 100644 --- a/apps/api/src/contexts/resources/application/report-resource-validity.ts +++ b/apps/api/src/contexts/resources/application/report-resource-validity.ts @@ -38,6 +38,7 @@ export class ReportResourceValidity { private readonly reports: ResourceValidityReportRepository, private readonly bus: EventBus, private readonly threshold: number = DEFAULT_DISPUTE_THRESHOLD, + private readonly cooldownMs: number = 0, ) {} async execute( @@ -96,10 +97,10 @@ export class ReportResourceValidity { ResourceId.fromString(cmd.resourceId), ); if (current && !current.disputed) { - current.flagDisputed(); + current.flagDisputed(this.cooldownMs); await this.resources.save(current); await this.bus.publish(current.pullDomainEvents()); - disputed = true; + disputed = current.disputed; } else if (current) { disputed = current.disputed; } diff --git a/apps/api/src/contexts/resources/domain/resource-dispute.spec.ts b/apps/api/src/contexts/resources/domain/resource-dispute.spec.ts index 6e14d77c..5361f732 100644 --- a/apps/api/src/contexts/resources/domain/resource-dispute.spec.ts +++ b/apps/api/src/contexts/resources/domain/resource-dispute.spec.ts @@ -97,4 +97,53 @@ describe('Resource dispute', () => { expect(back.disputed).toBe(true); expect(back.disputedAt).toBeInstanceOf(Date); }); + + it('clearDispute("dismiss") sets disputeDismissedAt; other resolutions do not', () => { + const r = published(); + r.flagDisputed(); + r.pullDomainEvents(); + + r.clearDispute('dismiss'); + expect(r.disputeDismissedAt).toBeInstanceOf(Date); + + const r2 = published(); + r2.flagDisputed(); + r2.pullDomainEvents(); + r2.clearDispute('confirm_closed'); + expect(r2.disputeDismissedAt).toBeNull(); + }); + + it('flagDisputed within cooldown window is a no-op (no event, stays undisputed)', () => { + const r = published(); + r.flagDisputed(); + r.pullDomainEvents(); + r.clearDispute('dismiss'); + r.pullDomainEvents(); + + r.flagDisputed(86_400_000); + + expect(r.disputed).toBe(false); + expect(r.pullDomainEvents()).toEqual([]); + }); + + it('flagDisputed after cooldown expires works normally', () => { + const snap = published().toSnapshot(); + snap.disputeDismissedAt = new Date(Date.now() - 90_000_000); // 25h ago + const r = Resource.fromSnapshot(snap); + + r.flagDisputed(86_400_000); + + expect(r.disputed).toBe(true); + expect(r.pullDomainEvents().map((e) => e.eventName)).toEqual([ + 'resource.disputed', + ]); + }); + + it('disputeDismissedAt survives a snapshot round-trip', () => { + const r = published(); + r.flagDisputed(); + r.clearDispute('dismiss'); + const back = Resource.fromSnapshot(r.toSnapshot()); + expect(back.disputeDismissedAt).toBeInstanceOf(Date); + }); }); diff --git a/apps/api/src/contexts/resources/domain/resource.ts b/apps/api/src/contexts/resources/domain/resource.ts index 22fe1667..b00a960c 100644 --- a/apps/api/src/contexts/resources/domain/resource.ts +++ b/apps/api/src/contexts/resources/domain/resource.ts @@ -100,6 +100,7 @@ export interface ResourceSnapshot { items: SupplyLineSnapshot[]; disputed?: boolean; disputedAt?: Date | null; + disputeDismissedAt?: Date | null; /** Optional (legacy-safe) restricted author attribution (#235). */ author?: AuthorSnapshot | null; } @@ -132,6 +133,7 @@ export class Resource { private _items: SupplyLine[], private _disputed: boolean, private _disputedAt: Date | null, + private _disputeDismissedAt: Date | null, public readonly author: Author | null, ) {} @@ -167,6 +169,7 @@ export class Resource { props.items ?? [], false, null, + null, props.author ?? null, ); r.events.push( @@ -206,6 +209,7 @@ export class Resource { (s.items ?? []).map((i) => SupplyLine.fromSnapshot(i)), s.disputed ?? false, s.disputedAt ?? null, + s.disputeDismissedAt ?? null, s.author ? Author.fromSnapshot(s.author) : null, ); } @@ -234,6 +238,9 @@ export class Resource { get disputedAt(): Date | null { return this._disputedAt; } + get disputeDismissedAt(): Date | null { + return this._disputeDismissedAt; + } /** Declared inventory the place holds for delivery (#28, #129). */ get items(): SupplyLine[] { return this._items; @@ -379,12 +386,19 @@ export class Resource { * until a coordinator resolves the dispute. Only a visible (published) point * can be disputed. Idempotent: flagging an already-disputed resource is a * no-op (no duplicate event, the original disputedAt is kept). + * + * cooldownMs: if positive and the resource was recently dismissed, the + * re-dispute is suppressed until the window expires (anti-abuse loop guard). */ - flagDisputed(): void { + flagDisputed(cooldownMs = 0): void { if (this._disputed) return; if (!this.isPubliclyVisible()) { throw new ResourceNotDisputableError(); } + if (cooldownMs > 0 && this._disputeDismissedAt !== null) { + const elapsed = Date.now() - this._disputeDismissedAt.getTime(); + if (elapsed < cooldownMs) return; + } this._disputed = true; this._disputedAt = new Date(); this.events.push( @@ -398,6 +412,9 @@ export class Resource { clearDispute(resolution: string): void { this._disputed = false; this._disputedAt = null; + if (resolution === 'dismiss') { + this._disputeDismissedAt = new Date(); + } this.events.push( new ResourceDisputeResolved(this.id.value, { emergencyId: this.emergencyId.value, @@ -440,6 +457,7 @@ export class Resource { items: this.items.map((i) => i.toSnapshot()), disputed: this._disputed, disputedAt: this._disputedAt, + disputeDismissedAt: this._disputeDismissedAt, author: this.author ? this.author.toSnapshot() : null, }; } 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 90e3af1c..070ce7da 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 @@ -82,6 +82,7 @@ type RawRow = { recipient_type: string | null; disputed: boolean | null; disputed_at: unknown; + dispute_dismissed_at: unknown; author?: unknown; }; @@ -157,6 +158,7 @@ function rawRowToSnapshot(row: RawRow): ResourceSnapshot { recipientType: row.recipient_type ?? null, disputed: row.disputed ?? false, disputedAt: toDateOrNull(row.disputed_at), + disputeDismissedAt: toDateOrNull(row.dispute_dismissed_at), author: (row.author as AuthorSnapshot | null | undefined) ?? null, // Raw SQL paths (nearby) power the map, which does not render inventory — // items are intentionally not hydrated here to keep the payload lean. @@ -205,6 +207,7 @@ function rowToSnapshot(row: Row, items: ItemsRow[] = []): ResourceSnapshot { recipientType: row.recipientType ?? null, disputed: row.disputed ?? false, disputedAt: row.disputedAt ?? null, + disputeDismissedAt: row.disputeDismissedAt ?? null, author: row.author ?? null, items: itemsToSnapshots(items), }; @@ -260,6 +263,7 @@ export class DrizzleResourceRepository implements ResourceRepository { recipientType: s.recipientType, disputed: s.disputed ?? false, disputedAt: s.disputedAt ?? null, + disputeDismissedAt: s.disputeDismissedAt ?? null, author: s.author ?? null, }) .onConflictDoUpdate({ @@ -282,6 +286,7 @@ export class DrizzleResourceRepository implements ResourceRepository { recipientType: s.recipientType, disputed: s.disputed ?? false, disputedAt: s.disputedAt ?? null, + disputeDismissedAt: s.disputeDismissedAt ?? null, }, }); diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts index 05bdf62d..1b31e7c3 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts @@ -43,6 +43,7 @@ export const resourcesTable = pgTable('resources', { // `disputed` = varios ciudadanos lo han reportado como cerrado/inexistente/… disputed: boolean('disputed').notNull().default(false), disputedAt: timestamp('disputed_at', { withTimezone: true }), + disputeDismissedAt: timestamp('dispute_dismissed_at', { withTimezone: true }), /** Restricted self-reported author attribution (#235). Never public. */ author: jsonb('author').$type(), }); 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 002befd0..514d58f6 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts @@ -10,6 +10,7 @@ import { Req, UseGuards, } from '@nestjs/common'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { ApiTags, ApiOperation, @@ -387,7 +388,8 @@ export class ResourcesController { @Post('resources/:resourceId/validity-reports') @HttpCode(201) - @UseGuards(JwtAuthGuard) + @UseGuards(ThrottlerGuard, JwtAuthGuard) + @Throttle({ validity: { ttl: 3_600_000, limit: 20 } }) @ApiBearerAuth() @ApiOperation({ summary: diff --git a/apps/api/src/contexts/resources/infrastructure/resources.module.ts b/apps/api/src/contexts/resources/infrastructure/resources.module.ts index 18d5ba65..f6c74404 100644 --- a/apps/api/src/contexts/resources/infrastructure/resources.module.ts +++ b/apps/api/src/contexts/resources/infrastructure/resources.module.ts @@ -242,7 +242,18 @@ const reportResourceValidityProvider = { ) => { const raw = Number(process.env.RESOURCE_DISPUTE_THRESHOLD); const threshold = Number.isFinite(raw) && raw > 0 ? raw : undefined; - return new ReportResourceValidity(repo, validityRepo, bus, threshold); + const rawCooldown = Number(process.env.RESOURCE_DISPUTE_COOLDOWN_HOURS); + const cooldownMs = + Number.isFinite(rawCooldown) && rawCooldown > 0 + ? rawCooldown * 3_600_000 + : 0; + return new ReportResourceValidity( + repo, + validityRepo, + bus, + threshold, + cooldownMs, + ); }, };