Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/drizzle/0044_resource_dispute_cooldown.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE resources
ADD COLUMN IF NOT EXISTS dispute_dismissed_at timestamptz;
--> statement-breakpoint
6 changes: 6 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down
49 changes: 49 additions & 0 deletions apps/api/src/contexts/resources/domain/resource-dispute.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
20 changes: 19 additions & 1 deletion apps/api/src/contexts/resources/domain/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
) {}

Expand Down Expand Up @@ -167,6 +169,7 @@ export class Resource {
props.items ?? [],
false,
null,
null,
props.author ?? null,
);
r.events.push(
Expand Down Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type RawRow = {
recipient_type: string | null;
disputed: boolean | null;
disputed_at: unknown;
dispute_dismissed_at: unknown;
author?: unknown;
};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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({
Expand All @@ -282,6 +286,7 @@ export class DrizzleResourceRepository implements ResourceRepository {
recipientType: s.recipientType,
disputed: s.disputed ?? false,
disputedAt: s.disputedAt ?? null,
disputeDismissedAt: s.disputeDismissedAt ?? null,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthorSnapshot>(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Req,
UseGuards,
} from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import {
ApiTags,
ApiOperation,
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
},
};

Expand Down
Loading