feat(internal-admin): VIN cache-clear + delete endpoints
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
@@ -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 {}
|
||||
|
||||
55
apps/api/src/internal-admin/vehicles.controller.ts
Normal file
55
apps/api/src/internal-admin/vehicles.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
108
apps/api/src/internal-admin/vehicles.service.ts
Normal file
108
apps/api/src/internal-admin/vehicles.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user