• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rustymotors / server / 25630156348

10 May 2026 01:35PM UTC coverage: 43.364% (-0.8%) from 44.206%
25630156348

push

github

drazisil
Add parts handlers and live parts catalog web endpoint

Implement MCOTS handlers for MC_BUY_NEW_PART (176), MC_REPAIR_SINGLE_PART
(177), MC_REPAIR_MULTIPLE_PARTS (178), MC_INSTALL_PART (181), MC_REMOVE_PART
(182), MC_GET_ASSEMBLY_PARTS (183), and MC_DESTROY_PART (214), with
corresponding DB helpers in cache.ts that invalidate the vehicle part tree
cache on mutation.

Replace the hardcoded branded-part-ID list in the /urls?id=58 handler with a
live PostgreSQL query filtered by abstract_part_type_id (apt) and
engine_block_family_id (bf). Update URL 58 in MC_GET_GAME_URLS to include
%d/%d format specifiers so the client passes its selected APT and block family
to the server. Add a dedicated /catalog/parts route that handles the same
query for clients using the new URL template directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

657 of 1756 branches covered (37.41%)

Branch coverage included in aggregate %.

6 of 178 new or added lines in 10 files covered. (3.37%)

2 existing lines in 2 files now uncovered.

4179 of 9396 relevant lines covered (44.48%)

129.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

2.37
/packages/database/src/cache.ts
1
import { getServerLogger, type ServerLogger } from "rusty-motors-shared";
2
import type { TBrand } from "./models/Brand.js";
3
import { getSlonik, getDatabase } from "./services/database.js";
4
import * as Sentry from "@sentry/node";
5
import type { TPart } from "./models/Part.js";
6
import { getDatabaseManager } from "./DatabaseManager.js";
7

8
// Lazy initialization - only connect when needed
9
let slonik: Awaited<ReturnType<typeof getDatabase>>["slonik"] | null = null;
2✔
10
let sql: Awaited<ReturnType<typeof getDatabase>>["sql"] | null = null;
2✔
11

12
async function ensureDatabase() {
13
    if (!slonik || !sql) {
×
14
        const db = await getDatabase();
×
15
        slonik = db.slonik;
×
16
        sql = db.sql;
×
17
    }
18
    return { slonik, sql };
×
19
}
20

21
const level1PartTypes = [1001, 2001, 4001, 5001, 6001, 15001, 36001, 37001];
2✔
22

23
const partNumbersMap = new Map<number, number>();
2✔
24

25
export type TVehicle = {
26
    vehicle_id: number;
27
    skin_id: number;
28
    flags: number;
29
    class: number;
30
    info_setting: number;
31
    damage_info: Buffer | null;
32
};
33

34
export type VehiclePartTreeType = {
35
    vehicleId: number;
36
    skinId: number;
37
    flags: number;
38
    class: number;
39
    infoSetting: number;
40
    damageInfo: Buffer | null;
41
    isStock: boolean;
42
    // One of the following two properties is required
43
    ownedLotId: number | null;
44
    ownerID: number | null;
45
    partId: number;
46
    parentPartId: null;
47
    brandedPartId: number;
48
    partTree: {
49
        level1: {
50
            partId: number;
51
            parts: TPart[];
52
        };
53
        level2: {
54
            partId: number;
55
            parts: TPart[];
56
        };
57
    };
58
};
59

60
export function vehiclePartTreeToJSON(
61
    vehiclePartTree: VehiclePartTreeType,
62
): string {
63
    const level1Parts = vehiclePartTree.partTree.level1.parts.map((part) => ({
×
64
        partId: part.part_id,
65
        parentPartId: part.parent_part_id,
66
        brandedPartId: part.branded_part_id,
67
        percentDamage: part.percent_damage,
68
        itemWear: part.item_wear,
69
        attachmentPointId: part.attachment_point_id,
70
        ownerId: part.owner_id,
71
        partName: part.part_name,
72
        repairCost: part.repair_cost,
73
        scrapValue: part.scrap_value,
74
    }));
75

76
    const level2Parts = vehiclePartTree.partTree.level2.parts.map((part) => ({
×
77
        partId: part.part_id,
78
        parentPartId: part.parent_part_id,
79
        brandedPartId: part.branded_part_id,
80
        percentDamage: part.percent_damage,
81
        itemWear: part.item_wear,
82
        attachmentPointId: part.attachment_point_id,
83
        ownerId: part.owner_id,
84
        partName: part.part_name,
85
        repairCost: part.repair_cost,
86
        scrapValue: part.scrap_value,
87
    }));
88

89
    return JSON.stringify({
×
90
        vehicleId: vehiclePartTree.vehicleId,
91
        skinId: vehiclePartTree.skinId,
92
        flags: vehiclePartTree.flags,
93
        class: vehiclePartTree.class,
94
        infoSetting: vehiclePartTree.infoSetting,
95
        damageInfo: vehiclePartTree.damageInfo,
96
        isStock: vehiclePartTree.isStock,
97
        ownedLotId: vehiclePartTree.ownedLotId,
98
        ownerID: vehiclePartTree.ownerID,
99
        partId: vehiclePartTree.partId,
100
        parentPartId: vehiclePartTree.parentPartId,
101
        brandedPartId: vehiclePartTree.brandedPartId,
102
        partTree: {
103
            level1: {
104
                partId: vehiclePartTree.partTree.level1.partId,
105
                parts: level1Parts,
106
            },
107
            level2: {
108
                partId: vehiclePartTree.partTree.level2.partId,
109
                parts: level2Parts,
110
            },
111
        },
112
    });
113
}
114

115
const brandCache = new Map<string, TBrand>();
2✔
116

117
export async function getBrand(brandName: string): Promise<TBrand | undefined> {
118
    if (brandCache.has(brandName)) {
×
119
        return brandCache.get(brandName);
×
120
    }
121

122
    const { slonik, sql } = await getSlonik();
×
123

124
    const brand = await slonik.one(sql.typeAlias("brand")`
×
125
        SELECT brandid, brand, isstock FROM brand WHERE brandname = ${brandName}
126
    `) as TBrand
127
    brandCache.set(brandName, brand);
×
128
    return brand;
×
129

130

131
}
132

133
const vehiclePartTreeCache = new Map<number, VehiclePartTreeType>();
2✔
134

135
export async function getVehiclePartTree(
136
    vehicleId: number,
137
): Promise<VehiclePartTreeType | undefined> {
138
    if (vehiclePartTreeCache.has(vehicleId)) {
×
139
        return vehiclePartTreeCache.get(vehicleId);
×
140
    }
141

142
    return undefined;
×
143
}
144

145
export async function setVehiclePartTree(
146
    vehicleId: number,
147
    vehiclePartTree: VehiclePartTreeType,
148
): Promise<void> {
149
    vehiclePartTreeCache.set(vehicleId, vehiclePartTree);
×
150
}
151

152
export async function saveVehicle(
153
    vehiclePartTree: VehiclePartTreeType,
154
): Promise<void> {
155
    const log = getServerLogger("database/saveVehicle")
×
156
    try {
×
157
        const vehiclePart: TPart = {
×
158
            part_id: vehiclePartTree.vehicleId,
159
            parent_part_id: null,
160
            branded_part_id: vehiclePartTree.brandedPartId,
161
            percent_damage: 0,
162
            item_wear: 0,
163
            attachment_point_id: null,
164
            owner_id: vehiclePartTree.ownerID || null,
×
165
            part_name: null,
166
            repair_cost: 0,
167
            scrap_value: 0,
168
        };
169

170
        log.debug(`Saving vehicle part: ${JSON.stringify(vehiclePart)}`,
×
171
            { vehicleId: vehiclePartTree.vehicleId });
172
        await savePart(vehiclePart).catch((error) => {
×
173
            log.error(`Error saving vehicle part: ${error}`);
×
174
            const e = new Error(`Error saving vehicle part: ${error}`);
×
175
            e.cause = error;
×
176
            throw e;
×
177
        });
178

179
        const newVehicle: TVehicle = {
×
180
            vehicle_id: vehiclePartTree.vehicleId,
181
            skin_id: vehiclePartTree.skinId,
182
            flags: vehiclePartTree.flags,
183
            class: vehiclePartTree.class,
184
            info_setting: vehiclePartTree.infoSetting,
185
            damage_info: vehiclePartTree.damageInfo,
186
        };
187

188
        log.debug(`Saving vehicle: ${JSON.stringify(newVehicle)}`, {
×
189
            vehicleId: vehiclePartTree.vehicleId
190
        });
191

192
        await Sentry.startSpan(
×
193
            {
194
                name: 'Save vehicle',
195
                op: 'db.query',
196
                attributes: {
197
                    sql: 'INSERT INTO vehicle (vehicleid, skinid, flags, class, infosetting, damageinfo) VALUES ($1, $2, $3, $4, $5, $6)',
198
                    db: 'postgres',
199
                },
200
            },
201
            async () => {
202
                const { slonik, sql } = await getSlonik();
×
203
                return slonik.query(sql.typeAlias('vehicle')`
×
204
            INSERT INTO vehicle (
205
                vehicle_id,
206
                skin_id,
207
                flags,
208
                class,
209
                info_setting,
210
                damage_info
211
            ) VALUES (
212
                ${newVehicle.vehicle_id},
213
                ${newVehicle.skin_id},
214
                ${newVehicle.flags},
215
                ${newVehicle.class},
216
                ${newVehicle.info_setting},
217
                ${newVehicle.damage_info}
218
            )
219
        `);
220
            },
221
        ).catch((error) => {
222
            log.error(`Error saving vehicle(db): ${error}`);
×
223
            const e = new Error(`Error saving vehicle: ${error}`);
×
224
            e.cause = error;
×
225
            throw e;
×
226
        }
227
        );
228
    } catch (error) {
229
        log.error(`Error saving vehicle: ${error}`);
×
230
        throw error;
×
231
    }
232
}
233

234
export async function saveVehiclePartTree(
235
    vehiclePartTree: VehiclePartTreeType,
236
): Promise<void> {
237
    const log = getServerLogger("database/saveVehiclePartTree")
×
238
    try {
×
239
        const partIds = new Set<number>();
×
240

241
        const partTree = vehiclePartTree.partTree;
×
242

243
        for (const part of partTree.level1.parts) {
×
244
            partIds.add(part.part_id);
×
245
        }
246

247
        for (const part of partTree.level2.parts) {
×
248
            partIds.add(part.part_id);
×
249
        }
250

251
        for (const partId of partIds) {
×
252
            const part =
253
                partTree.level1.parts.find((p) => p.part_id === partId) ||
×
254
                partTree.level2.parts.find((p) => p.part_id === partId);
×
255
            if (!part) {
×
256
                log.error(`Part with partId ${partId} not found`);
×
257
                throw new Error(`Part with partId ${partId} not found`);
×
258
            }
259
            await savePart(part);
×
260
        }
261

262
        // Save the vehicle part tree in the cache
263
        setVehiclePartTree(vehiclePartTree.vehicleId, vehiclePartTree);
×
264
    } catch (error) {
265
        log.error(`Error saving vehicle part tree: ${error}`);
×
266
        throw error;
×
267
    }
268
}
269

270
export async function buildVehiclePartTreeFromDB(
271
    vehicleId: number,
272
): Promise<VehiclePartTreeType> {
273
    const log = getServerLogger("database/cache");
×
274
    const { slonik, sql } = await ensureDatabase();
×
275
    const vehicle = await Sentry.startSpan(
×
276
        {
277
            name: "Get vehicle",
278
            op: "db.query",
279
            attributes: {
280
                sql: "SELECT vehicle_id, skin_id, flags, class, info_setting, damage_info FROM vehicle WHERE vehicle_id = $1",
281
                db: "postgres",
282
            },
283
        },
284
        async () => {
285
            return slonik.one(sql.typeAlias("vehicle")`
×
286
        SELECT vehicle_id, skin_id, flags, class, info_setting, damage_info
287
        FROM vehicle
288
        WHERE vehicle_id = ${vehicleId}
289
    `);
290
        }
291
    ) as TVehicle;
292

293
    if (!vehicle) {
×
294
        log.error(`Vehicle with id ${vehicleId} does not exist`);
×
295
        throw new Error(`Vehicle with id ${vehicleId} does not exist`);
×
296
    }
297

298
    const vehiclePartTree: VehiclePartTreeType = {
×
299
        vehicleId: vehicle.vehicle_id,
300
        skinId: vehicle.skin_id,
301
        flags: vehicle.flags,
302
        class: vehicle.class,
303
        infoSetting: vehicle.info_setting,
304
        damageInfo: vehicle.damage_info,
305
        isStock: false,
306
        ownedLotId: null,
307
        ownerID: null,
308
        partId: vehicle.vehicle_id,
309
        parentPartId: null,
310
        brandedPartId: 0,
311
        partTree: {
312
            level1: {
313
                partId: 0,
314
                parts: [],
315
            },
316
            level2: {
317
                partId: 0,
318
                parts: [],
319
            },
320
        },
321
    };
322

323
    // Get first part
324
    const part = await slonik.one(sql.typeAlias("part")`
×
325
        SELECT part_id, parent_part_id, branded_part_id, percent_damage, item_wear, attachment_point_id, owner_id, part_name, repair_cost, scrap_value
326
        FROM part
327
        WHERE part_id = ${vehicleId}
328
    `) as TPart
329

330
    if (!part) {
×
331
        log.error(`Part with id ${vehicleId} does not exist`);
×
332
        throw new Error(`Part with id ${vehicleId} does not exist`);
×
333
    }
334

335
    vehiclePartTree.brandedPartId = part.branded_part_id;
×
336
    vehiclePartTree.ownerID = part.owner_id;
×
337

338
    const level1Parts = await Sentry.startSpan(
×
339
        {
340
            name: "Get level 1 parts",
341
            op: "db.query",
342
            attributes: {
343
                sql: "SELECT partid, parentpartid, brandedpartid, percentdamage, itemwear, attachmentpointid, ownerid, partname, repaircost, scrapvalue FROM part WHERE parentpartid = $1",
344
                db: "postgres",
345
            },
346
        },
347
        async () => {
348
            return slonik.many(sql.typeAlias("part")`
×
349
        SELECT part_id, parent_part_id, branded_part_id, percent_damage, item_wear, attachment_point_id, owner_id, part_name, repair_cost, scrap_value
350
        FROM part
351
        WHERE parent_part_id = ${vehicleId}
352
    `);
353
        },
354
    ) as TPart[];
355

356
    if (level1Parts.length === 0) {
×
357
        log.error(`Vehicle with id ${vehicleId} has no parts`);
×
358
        throw new Error(`Vehicle with id ${vehicleId} has no parts`);
×
359
    }
360

361
    log.debug(`We got parts!`, {
×
362
        BrandedPart: vehiclePartTree.brandedPartId
363
    });
364
    log.debug(
×
365
        `There are ${level1Parts.length} level 1 parts in the vehicle assembly`,
366
        { BrandedPart: vehiclePartTree.brandedPartId }
367
    );
368

369
    const level1PartsIds = level1Parts.map((part) => part.part_id);
×
370

371
    for (const part of level1Parts) {
×
372

373
        const newPart: TPart = {
×
374
            part_id: part.part_id,
375
            parent_part_id: part.parent_part_id,
376
            branded_part_id: part.branded_part_id,
377
            percent_damage: part.percent_damage,
378
            item_wear: part.item_wear,
379
            attachment_point_id: part.attachment_point_id,
380
            owner_id: part.owner_id,
381
            part_name: part.part_name,
382
            repair_cost: part.repair_cost,
383
            scrap_value: part.scrap_value,
384
        };
385

386
        vehiclePartTree.partTree.level1.parts.push(newPart);
×
387
    }
388

389
    const level2Parts = await (slonik.many(sql.typeAlias("part")`
×
390
        SELECT part_id, parent_part_id, branded_part_id, percent_damage, item_wear, attachment_point_id, owner_id, part_name, repair_cost, scrap_value
391
        FROM part
392
        WHERE parent_part_id IN (${sql.join(level1PartsIds, sql.fragment`, `)})
393
    `) as Promise<TPart[]>);
394

395

396
    if (level2Parts.length === 0) {
×
397
        log.error(`Vehicle with id ${vehicleId} has no level 2 parts`);
×
398
        throw new Error(`Vehicle with id ${vehicleId} has no level 2 parts`);
×
399
    }
400

401
    log.debug(`We got parts!`,
×
402
        { BrandedPart: vehiclePartTree.brandedPartId }
403
    );
404
    log.debug(
×
405
        `There are ${level2Parts.length} level 2 parts in the vehicle assembly`,
406
        { BrandedPart: vehiclePartTree.brandedPartId }
407
    );
408

409
    for (const part of level2Parts) {
×
410
        const newPart: TPart = {
×
411
            part_id: part.part_id,
412
            parent_part_id: part.parent_part_id,
413
            branded_part_id: part.branded_part_id,
414
            percent_damage: part.percent_damage,
415
            item_wear: part.item_wear,
416
            attachment_point_id: part.attachment_point_id,
417
            owner_id: part.owner_id,
418
            part_name: part.part_name,
419
            repair_cost: part.repair_cost,
420
            scrap_value: part.scrap_value,
421
        };
422

423
        vehiclePartTree.partTree.level2.parts.push(newPart);
×
424
    }
425

426
    log.debug(`Vehicle part tree populated`,
×
427
        { BrandedPart: vehiclePartTree.brandedPartId }
428
    );
429
    log.debug(`Vehicle part tree: ${vehiclePartTreeToJSON(vehiclePartTree)}`, {
×
430
        BrandedPart: vehiclePartTree.brandedPartId
431
    });
432

433
    setVehiclePartTree(vehiclePartTree.vehicleId, vehiclePartTree);
×
434

435
    return vehiclePartTree;
×
436
}
437

438
export async function savePart(part: TPart): Promise<void> {
439
    await Sentry.startSpan(
×
440
        {
441
            name: 'Save part',
442
            op: 'db.query',
443
            attributes: {
444
                sql: 'INSERT INTO part (partid, parentpartid, brandedpartid, percentdamage, itemwear, attachmentpointid, ownerid, partname, repaircost, scrapvalue) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
445
                db: 'postgres',
446
            },
447
        },
448
        async () => {
449
            const { slonik, sql } = await getSlonik();
×
450
            return slonik.query(sql.typeAlias('dbPart')`
×
451
        INSERT INTO part (
452
            part_id,
453
            parent_part_id,
454
            branded_part_id,
455
            percent_damage,
456
            item_wear,
457
            attachment_point_id,
458
            owner_id,
459
            part_name,
460
            repair_cost,
461
            scrap_value
462
        ) VALUES (
463
            ${part.part_id},
464
            ${part.parent_part_id},
465
            ${part.branded_part_id},
466
            ${part.percent_damage},
467
            ${part.item_wear},
468
            ${part.attachment_point_id},
469
            ${part.owner_id},
470
            ${part.part_name},
471
            ${part.repair_cost},
472
            ${part.scrap_value}
473
        )
474
    `);
475
        },
476
    );
477
}
478

479
async function getNextPartId(): Promise<number> {
480
    const result = await Sentry.startSpan(
×
481
        {
482
            name: 'Get next part id',
483
            op: 'db.query',
484
            attributes: {
485
                sql: "SELECT nextval('part_partid_seq')",
486
                db: 'postgres',
487
            },
488
        },
489
        async () => {
490
            const { slonik, sql } = await getSlonik();
×
491
            const { nextval } = await slonik.one(sql.typeAlias('nextPartId')`
×
492
            SELECT nextval('part_partid_seq')
493
        `) as { nextval: number };
494
            return Number(nextval);
×
495
        },
496
    );
497
    return result;
×
498
}
499

500
export async function buildVehiclePartTree({
501
    brandedPartId,
502
    skinId,
503
    ownedLotId,
504
    ownerID,
505
    isStock,
506
    log = getServerLogger("database/buildVehiclePartTree")
×
507
}: {
508
    brandedPartId: number;
509
    skinId: number;
510
    ownedLotId?: number;
511
    ownerID?: number;
512
    isStock: boolean;
513
    log?: ServerLogger
514
}): Promise<VehiclePartTreeType> {
515
    if (ownedLotId === undefined && ownerID === undefined) {
×
516
        log.error(`ownedLotId or ownerID is required`);
×
517
        throw new Error('ownedLotId or ownerID is required');
×
518
    }
519

520
    const skinFlags = await Sentry.startSpan(
×
521
        {
522
            name: 'Get skin flags',
523
            op: 'db.query',
524
            attributes: {
525
                sql: 'SELECT default_flag FROM pt_skin WHERE skin_id = $1',
526
                db: 'postgres',
527
            },
528
        },
529
        async () => {
530
            const { slonik, sql } = await getSlonik();
×
531
            return slonik.one(sql.typeAlias('ptSkin')`
×
532
        SELECT default_flag
533
        FROM pt_skin
534
        WHERE skin_id = ${skinId}
535
    `);
536
        },
537
    ) as { default_flag: number };
538

539
    if (!skinFlags) {
×
540
        log.error(`Skin with id ${skinId} does not exist`);
×
541
        throw new Error(`Skin with id ${skinId} does not exist`);
×
542
    }
543

544
    // Get the vehicle assembly from the database
545
    const { slonik, sql } = await getSlonik();
×
546
    const vehicleAssembly = await slonik.many(sql.typeAlias('detailedPart')`
×
547
        SELECT bp.branded_part_id, bp.part_type_id, a.attachment_point_id, pt.abstract_part_type_id, apt.parent_abstract_part_type_id
548
        FROM stock_assembly a
549
        INNER JOIN branded_part bp ON a.child_branded_part_id = bp.branded_part_id
550
        inner join part_type pt on pt.part_type_id = bp.part_type_id
551
        inner join abstract_part_type apt on apt.abstract_part_type_id = pt.abstract_part_type_id
552
        WHERE a.parent_branded_part_id = ${brandedPartId}
553
    `) as {
554
        branded_part_id: number;
555
        part_type_id: number;
556
        abstract_part_type_id: number;
557
        parent_abstract_part_type_id: number;
558
        attachment_point_id: number;
559
    }[];
560

561
    if (vehicleAssembly.length === 0) {
×
562
        log.error(
×
563
            `Vehicle assembly with branded part id ${brandedPartId} does not exist`,
564
        );
565
        throw new Error(
×
566
            `Vehicle assembly with branded part id ${brandedPartId}`,
567
        );
568
    }
569

570
    // But we did get parts, right?
571
    log.debug(`We got parts!`,
×
572
        { BrandedPart: brandedPartId }
573
    );
574
    log.debug(
×
575
        `There are ${vehicleAssembly.length} parts in the vehicle assembly`,
576
        { brandedPart: brandedPartId }
577
    );
578

579
    const topPartId = await getNextPartId();
×
580

581
    partNumbersMap.set(101, topPartId);
×
582

583
    const vehiclePartTree: VehiclePartTreeType = {
×
584
        vehicleId: topPartId,
585
        skinId,
586
        isStock,
587
        flags: skinFlags.default_flag,
588
        class: 0,
589
        infoSetting: 0,
590
        damageInfo: null,
591
        ownedLotId: ownedLotId || null,
×
592
        ownerID: ownerID || null,
×
593
        partId: topPartId,
594
        parentPartId: null,
595
        brandedPartId,
596
        partTree: {
597
            level1: {
598
                partId: 0,
599
                parts: [],
600
            },
601
            level2: {
602
                partId: 0,
603
                parts: [],
604
            },
605
        },
606
    };
607

608
    log.debug(`Vehicle part tree created`);
×
609

610
    // Populate the vehicle part tree
611
    for (const part of vehicleAssembly) {
×
612
        const parentPartId = partNumbersMap.get(part.parent_abstract_part_type_id);
×
613

614
        if (parentPartId === undefined) {
×
615
            log.error(
×
616
                `parentPartId is undefined for part with parentabstractparttypeid ${part.parent_abstract_part_type_id}`,
617
            );
618
            throw new Error(
×
619
                `parentPartId is undefined for part with parentabstractparttypeid ${part.parent_abstract_part_type_id}`,
620
            );
621
        }
622

623
        const thisPartId = await getNextPartId();
×
624

625
        if (!partNumbersMap.has(part.abstract_part_type_id)) {
×
626
            partNumbersMap.set(part.abstract_part_type_id, thisPartId);
×
627
        }
628

629
        const newPart: TPart = {
×
630
            part_id: thisPartId,
631
            parent_part_id: parentPartId,
632
            branded_part_id: part.branded_part_id,
633
            percent_damage: 0,
634
            item_wear: 0,
635
            attachment_point_id: part.attachment_point_id,
636
            owner_id: ownerID || null,
×
637
            part_name: null,
638
            repair_cost: 0,
639
            scrap_value: 0,
640
        };
641

642
        const partDepth = level1PartTypes.includes(part.abstract_part_type_id)
×
643
            ? 1
644
            : 2;
645

646
        if (partDepth === 1) {
×
647
            vehiclePartTree.partTree.level1.parts.push(newPart);
×
648
        } else if (partDepth === 2) {
×
649
            vehiclePartTree.partTree.level2.parts.push(newPart);
×
650
        } else {
651
            log.error(`Part depth ${partDepth} is not supported`);
×
652
            throw new Error(`Part depth ${partDepth} is not supported`);
×
653
        }
654
    }
655

656
    log.debug(`Vehicle part tree populated`);
×
657

658
    return vehiclePartTree;
×
659
}
660

661
export async function dbBuyNewPart(personiaId: number, brandedPartId: number, _dealerId: number, _shouldChargePlayer: boolean): Promise<number> {
662
    const persona = getDatabaseManager().getUser(personiaId)
×
663

664
    if (typeof persona === "undefined") {
×
665
        throw new Error(`Persona ${personiaId} not found`)
×
666
    }
667

668
    const newPartId = await getNextPartId();
×
669

670
    const newPart: TPart = {
×
671
        part_id: newPartId,
672
        parent_part_id: null,
673
        branded_part_id: brandedPartId,
674
        percent_damage: 0,
675
        item_wear: 0,
676
        attachment_point_id: 0,
677
        owner_id: personiaId,
678
        part_name: null,
679
        repair_cost: 0,
680
        scrap_value: 0,
681
    }
682

683

684
    return savePart(newPart).then(() => newPartId)
×
685
}
686

687
export function invalidateVehiclePartTree(vehicleId: number): void {
NEW
688
    vehiclePartTreeCache.delete(vehicleId);
×
689
}
690

691
async function getVehicleRootId(partId: number): Promise<number | null> {
NEW
692
    const { slonik, sql } = await getSlonik();
×
NEW
693
    const part = await slonik.maybeOne(sql.typeAlias('part')`
×
694
        SELECT part_id, parent_part_id FROM part WHERE part_id = ${partId}
695
    `) as TPart | null;
NEW
696
    if (!part) return null;
×
NEW
697
    if (!part.parent_part_id) return part.part_id;
×
NEW
698
    const parent = await slonik.maybeOne(sql.typeAlias('part')`
×
699
        SELECT part_id, parent_part_id FROM part WHERE part_id = ${part.parent_part_id}
700
    `) as TPart | null;
NEW
701
    if (!parent) return null;
×
NEW
702
    return parent.parent_part_id ?? parent.part_id;
×
703
}
704

705
export async function dbInstallPart(
706
    partId: number,
707
    parentPartId: number,
708
    attachmentPointId: number,
709
    ownerId: number,
710
): Promise<void> {
NEW
711
    await Sentry.startSpan(
×
712
        {
713
            name: 'Install part',
714
            op: 'db.query',
715
            attributes: {
716
                sql: 'UPDATE part SET parent_part_id = $1, attachment_point_id = $2 WHERE part_id = $3 AND owner_id = $4',
717
                db: 'postgres',
718
            },
719
        },
720
        async () => {
NEW
721
            const { slonik, sql } = await getSlonik();
×
NEW
722
            return slonik.query(sql.typeAlias('dbPart')`
×
723
                UPDATE part
724
                SET parent_part_id = ${parentPartId},
725
                    attachment_point_id = ${attachmentPointId}
726
                WHERE part_id = ${partId}
727
                AND owner_id = ${ownerId}
728
            `);
729
        },
730
    );
NEW
731
    const vehicleId = await getVehicleRootId(parentPartId);
×
NEW
732
    if (vehicleId !== null) {
×
NEW
733
        vehiclePartTreeCache.delete(vehicleId);
×
734
    }
735
}
736

737
export async function dbRepairPart(
738
    partId: number,
739
    ownerId: number,
740
): Promise<void> {
741
    // TODO (bank pass): price = RetailPrice if PercentDamage >= 70, else (PercentDamage/100)*RetailPrice
NEW
742
    const vehicleId = await getVehicleRootId(partId);
×
NEW
743
    await Sentry.startSpan(
×
744
        {
745
            name: 'Repair part',
746
            op: 'db.query',
747
            attributes: {
748
                sql: 'UPDATE part SET percent_damage = 0 WHERE part_id = $1 AND owner_id = $2',
749
                db: 'postgres',
750
            },
751
        },
752
        async () => {
NEW
753
            const { slonik, sql } = await getSlonik();
×
NEW
754
            return slonik.query(sql.typeAlias('dbPart')`
×
755
                UPDATE part
756
                SET percent_damage = 0
757
                WHERE part_id = ${partId}
758
                AND owner_id = ${ownerId}
759
            `);
760
        },
761
    );
NEW
762
    if (vehicleId !== null) {
×
NEW
763
        vehiclePartTreeCache.delete(vehicleId);
×
764
    }
765
}
766

767
export async function dbRepairVehicleParts(
768
    vehicleId: number,
769
    ownerId: number,
770
): Promise<void> {
771
    // TODO (bank pass): sum repair prices across all parts (same per-part formula as dbRepairPart)
NEW
772
    await Sentry.startSpan(
×
773
        {
774
            name: 'Repair vehicle parts',
775
            op: 'db.query',
776
            attributes: {
777
                sql: 'UPDATE part SET percent_damage = 0 WHERE owner_id = $1 AND (part_id = $2 OR parent_part_id = $2 OR parent_part_id IN (SELECT part_id FROM part WHERE parent_part_id = $2))',
778
                db: 'postgres',
779
            },
780
        },
781
        async () => {
NEW
782
            const { slonik, sql } = await getSlonik();
×
NEW
783
            return slonik.query(sql.typeAlias('dbPart')`
×
784
                UPDATE part
785
                SET percent_damage = 0
786
                WHERE owner_id = ${ownerId}
787
                AND (
788
                    part_id = ${vehicleId}
789
                    OR parent_part_id = ${vehicleId}
790
                    OR parent_part_id IN (
791
                        SELECT part_id FROM part WHERE parent_part_id = ${vehicleId}
792
                    )
793
                )
794
            `);
795
        },
796
    );
NEW
797
    vehiclePartTreeCache.delete(vehicleId);
×
798
}
799

800
export async function dbRemovePart(
801
    partId: number,
802
    ownerId: number,
803
): Promise<void> {
NEW
804
    const vehicleId = await getVehicleRootId(partId);
×
NEW
805
    await Sentry.startSpan(
×
806
        {
807
            name: 'Remove part',
808
            op: 'db.query',
809
            attributes: {
810
                sql: 'UPDATE part SET parent_part_id = NULL, attachment_point_id = NULL WHERE part_id = $1 AND owner_id = $2',
811
                db: 'postgres',
812
            },
813
        },
814
        async () => {
NEW
815
            const { slonik, sql } = await getSlonik();
×
NEW
816
            return slonik.query(sql.typeAlias('dbPart')`
×
817
                UPDATE part
818
                SET parent_part_id = NULL,
819
                    attachment_point_id = NULL
820
                WHERE part_id = ${partId}
821
                AND owner_id = ${ownerId}
822
            `);
823
        },
824
    );
NEW
825
    if (vehicleId !== null) {
×
NEW
826
        vehiclePartTreeCache.delete(vehicleId);
×
827
    }
828
}
829

830
export async function dbDestroyPart(
831
    partId: number,
832
    ownerId: number,
833
): Promise<void> {
NEW
834
    const vehicleId = await getVehicleRootId(partId);
×
NEW
835
    await Sentry.startSpan(
×
836
        {
837
            name: 'Destroy part',
838
            op: 'db.query',
839
            attributes: {
840
                sql: 'DELETE FROM part WHERE part_id = $1 AND owner_id = $2',
841
                db: 'postgres',
842
            },
843
        },
844
        async () => {
NEW
845
            const { slonik, sql } = await getSlonik();
×
NEW
846
            return slonik.query(sql.typeAlias('dbPart')`
×
847
                DELETE FROM part
848
                WHERE part_id = ${partId}
849
                AND owner_id = ${ownerId}
850
            `);
851
        },
852
    );
NEW
853
    if (vehicleId !== null) {
×
NEW
854
        vehiclePartTreeCache.delete(vehicleId);
×
855
    }
856
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc