feat(internal-admin): VIN cache-clear + delete endpoints
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

For the Süper Panel VIN management table — founder needs to be able to
flush a stale or wrong decode result and (rarely) blow away the shared
vehicles row so the next decode runs the full chain again.

POST /internal/admin/vehicles/:vin/cache-clear  { reason, founderId }
  Deletes vin:resolve:<vin>, vin:resolve:neg:<vin>, vin🔒<vin>.
  Returns { clearedKeys: [...], totalKeysChecked }. Safe no-op when
  nothing exists. Logs founder + reason.

DELETE /internal/admin/vehicles/:vin                { reason, founderId }
  Looks up the shared vehicles row by VIN; 404 if missing. Hard-deletes
  it — user_vehicles rows cascade via the existing FK on delete cascade.
  query_logs is intentionally NOT touched: it's audit history.
  Also clears the three Redis keys so the next decode starts fresh.
  Returns { vehicleId, brandName, model, source, cascadedUserLinks }.

Wired into InternalAdminModule. Reuses InternalTokenGuard + the public
decorator pattern the rest of the module uses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 07:54:38 +03:00
parent 0defbe7eb6
commit 1ecc00dfc3
3 changed files with 172 additions and 1 deletions

View File

@@ -8,6 +8,8 @@ import { ImpersonationService } from "./impersonation.service";
import { LifecycleController } from "./lifecycle.controller";
import { LifecycleService } from "./lifecycle.service";
import { PaymentsAdminController } from "./payments.controller";
import { InternalVehiclesController } from "./vehicles.controller";
import { InternalVehiclesService } from "./vehicles.service";
@Module({
imports: [SubscriptionsModule, StripeModule],
@@ -16,7 +18,13 @@ import { PaymentsAdminController } from "./payments.controller";
LifecycleController,
BillingController,
PaymentsAdminController,
InternalVehiclesController,
],
providers: [
ImpersonationService,
LifecycleService,
BillingService,
InternalVehiclesService,
],
providers: [ImpersonationService, LifecycleService, BillingService],
})
export class InternalAdminModule {}

View File

@@ -0,0 +1,55 @@
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
HttpStatus,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { InternalVehiclesService } from "./vehicles.service";
@Controller("internal/admin/vehicles")
@Public()
@UseGuards(InternalTokenGuard)
export class InternalVehiclesController {
constructor(private vehicles: InternalVehiclesService) {}
@Post(":vin/cache-clear")
@HttpCode(HttpStatus.OK)
async clearCache(
@Param("vin") vin: string,
@Body() body: { reason?: string; founderId?: string },
) {
if (!body.founderId) throw new BadRequestException("founderId required");
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException("reason required (min 5 chars)");
}
return this.vehicles.clearCache({
vin,
reason: body.reason,
founderId: body.founderId,
});
}
@Delete(":vin")
@HttpCode(HttpStatus.OK)
async deleteByVin(
@Param("vin") vin: string,
@Body() body: { reason?: string; founderId?: string },
) {
if (!body.founderId) throw new BadRequestException("founderId required");
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException("reason required (min 5 chars)");
}
return this.vehicles.deleteByVin({
vin,
reason: body.reason,
founderId: body.founderId,
});
}
}

View File

@@ -0,0 +1,108 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { isValidVin } from "@sase/shared";
import { eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { userVehicles, vehicles } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
@Injectable()
export class InternalVehiclesService {
private readonly logger = new Logger(InternalVehiclesService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
) {}
/**
* Clear all Süper Panel-visible Redis keys for a VIN:
* - vin:resolve:<vin> (positive decode cache)
* - vin:resolve:neg:<vin> (negative cache)
* - vin:lock:<vin> (in-flight decode lock)
* Safe to call when no keys exist (returns 0).
*/
async clearCache(input: { vin: string; reason: string; founderId: string }) {
const vin = input.vin.toUpperCase();
if (!isValidVin(vin)) throw new BadRequestException("Geçersiz VIN");
const keys = [`vin:resolve:${vin}`, `vin:resolve:neg:${vin}`, `vin:lock:${vin}`];
const existed: string[] = [];
for (const k of keys) {
if (await this.redis.exists(k)) existed.push(k);
await this.redis.del(k);
}
this.logger.log(
`vin cache-clear: vin=${vin} keys=${existed.length}/${keys.length} ` +
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
vin,
clearedKeys: existed,
totalKeysChecked: keys.length,
};
}
/**
* Hard-delete the shared `vehicles` row for a VIN. `user_vehicles` rows are
* cascade-deleted via the FK. Does NOT touch `query_logs` — that's audit
* history and should be preserved.
*
* Also clears Redis caches so the next decode attempt actually re-fetches
* from upstream providers instead of serving the negative cache.
*/
async deleteByVin(input: { vin: string; reason: string; founderId: string }) {
const vin = input.vin.toUpperCase();
if (!isValidVin(vin)) throw new BadRequestException("Geçersiz VIN");
const [existing] = await this.db
.select({
id: vehicles.id,
vin: vehicles.vin,
brandName: vehicles.brandName,
model: vehicles.model,
source: vehicles.source,
})
.from(vehicles)
.where(eq(vehicles.vin, vin))
.limit(1);
if (!existing) throw new NotFoundException("Bu VIN için vehicle kaydı yok");
// Count affected user_vehicles before the cascade for the audit response.
const linkedUsers = await this.db
.select({ userId: userVehicles.userId })
.from(userVehicles)
.where(eq(userVehicles.vehicleId, existing.id));
await this.db.delete(vehicles).where(eq(vehicles.id, existing.id));
// Best-effort Redis cleanup so a re-decode starts fresh.
for (const k of [`vin:resolve:${vin}`, `vin:resolve:neg:${vin}`, `vin:lock:${vin}`]) {
await this.redis.del(k).catch(() => {});
}
this.logger.log(
`vin delete: vin=${vin} vehicleId=${existing.id} ` +
`cascadedUserLinks=${linkedUsers.length} founder=${input.founderId} ` +
`reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
vin,
vehicleId: existing.id,
brandName: existing.brandName,
model: existing.model,
source: existing.source,
cascadedUserLinks: linkedUsers.length,
};
}
}