chore(lint): manual cleanup batch 4 — noNonNullAssertion + small fixes

- noNonNullAssertion (42 → 0): replaced ! assertions with explicit null guards across auth.module, storage.service, pl24-auth, categories.service, catalog.service, parts.service, emex.browser, emex.service, parts-catalogs-auth, pl24-ford-legacy, email.service, database.provider, jobs/processors, psa-variant-selector, vehicles/$id/categories, catalog/$brandName
- buildTree: skip orphaned items instead of asserting map.get
- emex.browser.acquirePage: explicit context check before newPage
- pcat-auth.getIstanbulTime: graceful UTC fallback if Intl parts missing
- email.send: gate on both postalApiUrl + postalApiKey for type narrowing
- categories.service: PSA root-fallback now early-returns when category.vehicleId missing (catalog-only categories don't have a sibling root)
- pl24-ford-legacy: createHash from "node:crypto", URL building uses single template literals, useDefaultParameterLast — required modelYear/engine/gearbox params (callers already supply them)
- pcat-auth: h.origin / h.referer literal-key access
- index.tsx: <button type="button"> on mobile menu toggle
- parts-panel + admin/users: keyboard handler for clickable rows (Enter/Space)
- ford-legacy: useOptionalChain on item.name?.toUpperCase()

Lint count: 155 → 114. Remaining: 121 noExplicitAny + 3 small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-09 17:10:28 +00:00
parent 80fddf2864
commit 7bc78a6f69
19 changed files with 124 additions and 98 deletions

View File

@@ -17,9 +17,14 @@ export class AuthModule implements OnModuleInit {
) {}
onModuleInit() {
const databaseUrl = this.configService.get<string>("database.url")!;
const secret = this.configService.get<string>("auth.secret")!;
const baseUrl = this.configService.get<string>("auth.url")!;
const databaseUrl = this.configService.get<string>("database.url");
const secret = this.configService.get<string>("auth.secret");
const baseUrl = this.configService.get<string>("auth.url");
if (!databaseUrl || !secret || !baseUrl) {
throw new Error(
"Auth module requires database.url, auth.secret, and auth.url to be set",
);
}
const googleClientId = this.configService.get<string>("auth.googleClientId");
const googleClientSecret = this.configService.get<string>("auth.googleClientSecret");
createAuth(databaseUrl, secret, baseUrl, {

View File

@@ -944,12 +944,11 @@ export class CatalogService {
description: p.description || null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId
? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return val > 0 && val <= 2147483647 ? val : null;
})()
: null,
hotspotIndex: ((): number | null => {
if (!p.hotspotId) return null;
const val = Number.parseInt(p.hotspotId, 10);
return val > 0 && val <= 2147483647 ? val : null;
})(),
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
@@ -1323,7 +1322,8 @@ export class CatalogService {
}
for (const item of items) {
const node = map.get(item.id)!;
const node = map.get(item.id);
if (!node) continue;
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId)?.children.push(node);
} else {

View File

@@ -437,10 +437,14 @@ export class CategoriesService {
// the groupId is a leaf-item reference, not a real sub-group ID), all returned
// groups will match existing root-level category externalIds for this vehicle.
// In that case, skip insertion — this category has no real sub-groups.
if (!category.vehicleId) {
// Catalog-only category (catalog vehicle) — root fallback irrelevant here
return [];
}
const rootExtIds = await this.db
.select({ externalId: categories.externalId })
.from(categories)
.where(eq(categories.vehicleId, category.vehicleId!));
.where(eq(categories.vehicleId, category.vehicleId));
const rootOnlyIds = rootExtIds
.filter((r) => r.externalId)
.map((r) => r.externalId as string);
@@ -914,12 +918,11 @@ export class CategoriesService {
description: p.description || null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId
? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return val > 0 && val <= 2147483647 ? val : null;
})()
: null,
hotspotIndex: ((): number | null => {
if (!p.hotspotId) return null;
const val = Number.parseInt(p.hotspotId, 10);
return val > 0 && val <= 2147483647 ? val : null;
})(),
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
@@ -994,10 +997,10 @@ export class CategoriesService {
`Failed to upload PSA schema image: ${(imgErr as Error).message}`,
);
}
} else {
} else if (pl24Result.schemaImageUrl) {
// P5 REST: download via getSchemaImage
const imageResult = await this.pl24Service.getSchemaImage(
pl24Result.schemaImageUrl!,
pl24Result.schemaImageUrl,
catalogInfo.serviceName,
);
@@ -1217,7 +1220,8 @@ export class CategoriesService {
}
for (const item of items) {
const node = map.get(item.id)!;
const node = map.get(item.id);
if (!node) continue;
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId)?.children.push(node);
} else {

View File

@@ -16,7 +16,8 @@ export type Database = PostgresJsDatabase<DatabaseSchema>;
export const DatabaseProvider: Provider = {
provide: DATABASE,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>("database.url")!;
const databaseUrl = configService.get<string>("database.url");
if (!databaseUrl) throw new Error("database.url is required");
const client = postgres(databaseUrl, {
max: 20,

View File

@@ -34,7 +34,7 @@ export class EmailService {
}
async send(options: SendEmailOptions): Promise<void> {
if (!this.isConfigured) {
if (!this.postalApiUrl || !this.postalApiKey) {
this.logger.log(`[DEV EMAIL] To: ${options.to}`);
this.logger.log(`[DEV EMAIL] Subject: ${options.subject}`);
this.logger.log(`[DEV EMAIL] Body: ${options.text || options.html.substring(0, 200)}`);
@@ -55,7 +55,7 @@ export class EmailService {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Server-API-Key": this.postalApiKey!,
"X-Server-API-Key": this.postalApiKey,
},
body: JSON.stringify(payload),
});

View File

@@ -106,7 +106,8 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
await this.ensureBrowser();
await this.ensureSession();
const page = await this.context!.newPage();
if (!this.context) throw new Error("EMEX browser context not initialized");
const page = await this.context.newPage();
let released = false;
const release = async () => {
@@ -230,14 +231,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
if (Date.now() < this.sessionExpiry) return;
this.logger.log("Establishing EMEX session...");
const page = await this.context!.newPage();
if (!this.context) throw new Error("EMEX browser context not initialized");
const ctx = this.context;
const page = await ctx.newPage();
try {
await page.goto(EMEX_BASE_URL, {
waitUntil: "networkidle",
timeout: 30000,
});
const cookies = await this.context!.cookies();
const cookies = await ctx.cookies();
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS;

View File

@@ -645,7 +645,7 @@ export class EmexService {
* Executes a promise with timeout
*/
private async executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: NodeJS.Timeout;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
@@ -657,10 +657,10 @@ export class EmexService {
try {
const result = await Promise.race([promise, timeoutPromise]);
clearTimeout(timeoutId!);
if (timeoutId) clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId!);
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
}

View File

@@ -347,8 +347,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
hour12: false,
}).formatToParts(new Date());
const hour = Number.parseInt(parts.find((p) => p.type === "hour")!.value, 10);
const minute = Number.parseInt(parts.find((p) => p.type === "minute")!.value, 10);
const hourPart = parts.find((p) => p.type === "hour");
const minutePart = parts.find((p) => p.type === "minute");
if (!hourPart || !minutePart) {
// Intl.DateTimeFormat with hour+minute always emits both parts; fall back to UTC if not.
const now = new Date();
return { hour: now.getUTCHours(), minute: now.getUTCMinutes() };
}
const hour = Number.parseInt(hourPart.value, 10);
const minute = Number.parseInt(minutePart.value, 10);
return { hour, minute };
}
@@ -495,7 +502,8 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
};
}
context = await this.browser!.newContext(contextOptions);
if (!this.browser) throw new Error("PCAT browser not initialized");
context = await this.browser.newContext(contextOptions);
const page = await context.newPage();
// Intercept the v3 widget call to /v3/api/proxy/* — needs the full
@@ -516,8 +524,8 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
apiPath: h["x-api-path"] || "https://api.parts-catalogs.com/v1",
guiVersion: h["x-gui-version"] || "3",
userId: h["x-user-id"] || "",
origin: h["origin"] || "",
referer: h["referer"] || "",
origin: h.origin || "",
referer: h.referer || "",
};
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
});

View File

@@ -74,12 +74,14 @@ export class PL24AuthService {
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
await this.login2();
}
return this.tokenData2!.accessToken;
if (!this.tokenData2) throw new Error("PL24 de account login failed");
return this.tokenData2.accessToken;
}
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
await this.login();
}
return this.tokenData!.accessToken;
if (!this.tokenData) throw new Error("PL24 tr account login failed");
return this.tokenData.accessToken;
}
/** Return session cookie for the given account. */
@@ -88,12 +90,14 @@ export class PL24AuthService {
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
await this.login2();
}
return this.tokenData2!.sessionCookie;
if (!this.tokenData2) throw new Error("PL24 de account login failed");
return this.tokenData2.sessionCookie;
}
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
await this.login();
}
return this.tokenData!.sessionCookie;
if (!this.tokenData) throw new Error("PL24 tr account login failed");
return this.tokenData.sessionCookie;
}
/** Authorize a service catalog for the given account and return the service token. */

View File

@@ -6,7 +6,7 @@
* HTML parsing via regex + string extraction (no external deps like cheerio).
*/
import { createHash } from "crypto";
import { createHash } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import type { ConfigService } from "@nestjs/config";
import type { RedisService } from "../../redis/redis.service";
@@ -515,8 +515,7 @@ export class PL24FordLegacyService {
for (const item of items) {
// Avoid duplicating family name prefix (e.g. family="BERLINGO", salesType="BERLINGO VP" → "BERLINGO VP")
const alreadyPrefixed =
item.name && item.name.toUpperCase().startsWith(family.name.toUpperCase());
const alreadyPrefixed = item.name?.toUpperCase().startsWith(family.name.toUpperCase());
const modelLabel =
item.name && !alreadyPrefixed && item.name !== family.name
? `${family.name} ${item.name}`
@@ -1629,9 +1628,9 @@ export class PL24FordLegacyService {
async fetchFordMainGroups(
serviceName: string,
familyId: string,
modelYear = "_all_",
engine = "_all_",
gearbox = "_all_",
modelYear: string,
engine: string,
gearbox: string,
mode: string,
upds: string,
catCode?: string,
@@ -1660,30 +1659,14 @@ export class PL24FordLegacyService {
let url: string;
if (arch === "LEGACY_HYUNDAI_KIA") {
url =
`${this.baseUrl}${basePath}/group.action` +
`?catalog=${encodeURIComponent(familyId)}` +
`&lang=tr&localMarketOnly=true&spec=e30%3D&startup=false` +
`&mode=${mode}&upds=${upds}`;
url = `${this.baseUrl}${basePath}/group.action?catalog=${encodeURIComponent(familyId)}&lang=tr&localMarketOnly=true&spec=e30%3D&startup=false&mode=${mode}&upds=${upds}`;
} else if (arch === "LEGACY_NISSAN") {
url =
`${this.baseUrl}${basePath}/group.action` +
`?model=${encodeURIComponent(familyId)}` +
`&lang=tr&localMarketOnly=true&spec=e30%3D&startup=false` +
`&mode=${mode}&upds=${upds}`;
url = `${this.baseUrl}${basePath}/group.action?model=${encodeURIComponent(familyId)}&lang=tr&localMarketOnly=true&spec=e30%3D&startup=false&mode=${mode}&upds=${upds}`;
} else if (arch === "LEGACY_OPEL") {
url =
`${this.baseUrl}${basePath}/group.action` +
`?catId=${encodeURIComponent(familyId)}` +
`&lang=tr&startup=false` +
`&mode=${mode}&upds=${upds}`;
url = `${this.baseUrl}${basePath}/group.action?catId=${encodeURIComponent(familyId)}&lang=tr&startup=false&mode=${mode}&upds=${upds}`;
} else if (arch === "LEGACY_VOLVO") {
const yearPart = modelYear !== "_all_" ? `&year=${encodeURIComponent(modelYear)}` : "";
url =
`${this.baseUrl}${basePath}/group.action` +
`?mdl=${encodeURIComponent(familyId)}` +
`&lang=tr&localMarketOnly=true&startup=false${yearPart}` +
`&mode=${mode}&upds=${upds}`;
url = `${this.baseUrl}${basePath}/group.action?mdl=${encodeURIComponent(familyId)}&lang=tr&localMarketOnly=true&startup=false${yearPart}&mode=${mode}&upds=${upds}`;
} else if (catCode) {
// LEGACY_FORD with catCode: catCode is the generation identifier required by group.action.
// The json-model-config.action also uses catCode to get engine options for this generation.
@@ -1694,14 +1677,7 @@ export class PL24FordLegacyService {
`&mode=${mode}&upds=${upds}`;
} else {
// LEGACY_FORD fallback (no catCode): old family+variant style — may return 500 for some families
url =
`${this.baseUrl}${basePath}/group.action` +
`?modelFamily=${encodeURIComponent(familyId)}` +
`&modelYear=${encodeURIComponent(modelYear)}` +
`&engine=${encodeURIComponent(engine)}` +
`&gearbox=${encodeURIComponent(gearbox)}` +
`&lang=tr&startup=false` +
`&mode=${mode}&upds=${upds}`;
url = `${this.baseUrl}${basePath}/group.action?modelFamily=${encodeURIComponent(familyId)}&modelYear=${encodeURIComponent(modelYear)}&engine=${encodeURIComponent(engine)}&gearbox=${encodeURIComponent(gearbox)}&lang=tr&startup=false&mode=${mode}&upds=${upds}`;
}
const html = await this.fetchP4Page(url, serviceName, true);
@@ -1939,11 +1915,14 @@ export class PL24FordLegacyService {
}
}
if (!vehicle.catalogInfo) {
throw new Error("PL24 PSA decode: vehicle.catalogInfo missing — required for psa flow");
}
const result: PL24DecodedVehicle = {
...vehicle,
categories: scopes,
catalogInfo: {
...vehicle.catalogInfo!,
...vehicle.catalogInfo,
psaFamilyId: familyId || undefined,
psaSalesTypeId: salesTypeId || undefined,
psaMode: mode,
@@ -2163,8 +2142,8 @@ export class PL24FordLegacyService {
return code && code !== "_all_" && !it.subheader && !it.gray;
})
.map((it) => ({
code: ((it[codeKey] as string) || (it.code as string))!,
name: ((it[nameKey] as string) || (it.name as string) || "")!,
code: (it[codeKey] as string) || (it.code as string),
name: (it[nameKey] as string) || (it.name as string) || "",
}));
} catch {
return [];

View File

@@ -30,10 +30,11 @@ export async function processEmexScrape(
console.log(`[emex-scrape] Processing job ${job.id} for VIN: ${vin}, user: ${userId}`);
// Update scrape session to active
if (!job.id) throw new Error("BullMQ job missing id");
const [session] = await db
.select()
.from(emexScrapeSessions)
.where(eq(emexScrapeSessions.jobId, job.id!))
.where(eq(emexScrapeSessions.jobId, job.id))
.limit(1);
if (session) {

View File

@@ -61,12 +61,11 @@ export class PartsService {
description: p.description || null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId
? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return val > 0 && val <= 2147483647 ? val : null;
})()
: null,
hotspotIndex: ((): number | null => {
if (!p.hotspotId) return null;
const val = Number.parseInt(p.hotspotId, 10);
return val > 0 && val <= 2147483647 ? val : null;
})(),
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,

View File

@@ -15,22 +15,27 @@ export class StorageService {
private readonly publicUrl: string;
constructor(private configService: ConfigService) {
const endpoint = configService.get<string>("minio.endpoint")!;
const endpoint = configService.get<string>("minio.endpoint");
const accessKeyId = configService.get<string>("minio.accessKey");
const secretAccessKey = configService.get<string>("minio.secretKey");
const publicUrl = configService.get<string>("minio.publicUrl");
if (!endpoint || !accessKeyId || !secretAccessKey || !publicUrl) {
throw new Error(
"Storage requires minio.endpoint, accessKey, secretKey, and publicUrl",
);
}
const useSSL = configService.get<boolean>("minio.useSSL", false);
this.s3 = new S3Client({
endpoint,
region: "us-east-1",
credentials: {
accessKeyId: configService.get<string>("minio.accessKey")!,
secretAccessKey: configService.get<string>("minio.secretKey")!,
},
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true,
...(useSSL ? {} : { tls: false }),
});
this.bucketName = configService.get<string>("minio.bucketName", "sase-schemas");
this.publicUrl = configService.get<string>("minio.publicUrl")!;
this.publicUrl = publicUrl;
}
async upload(key: string, body: Buffer | Uint8Array, contentType: string): Promise<string> {