Merge pull request 'feat(internal-admin): VIN cache-clear + delete' (#34) from dev into main

This commit was merged in pull request #34.
This commit is contained in:
2026-05-19 04:54:42 +00:00
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,
};
}
}