feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.

This commit is contained in:
syntaxbullet
2026-02-06 12:19:14 +01:00
parent 109b36ffe2
commit 34958aa220
22 changed files with 3718 additions and 15 deletions

View File

@@ -0,0 +1,215 @@
/**
* Items Service
* Handles CRUD operations for game items.
* Used by both bot commands and web dashboard.
*/
import { items } from "@db/schema";
import { eq, ilike, and, or, count, sql } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@shared/lib/types";
import type { ItemType } from "@shared/lib/constants";
// --- DTOs ---
export interface CreateItemDTO {
name: string;
description?: string | null;
rarity?: 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary';
type: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST';
price?: bigint | null;
iconUrl: string;
imageUrl: string;
usageData?: ItemUsageData | null;
}
export interface UpdateItemDTO {
name?: string;
description?: string | null;
rarity?: 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary';
type?: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST';
price?: bigint | null;
iconUrl?: string;
imageUrl?: string;
usageData?: ItemUsageData | null;
}
export interface ItemFilters {
search?: string;
type?: string;
rarity?: string;
limit?: number;
offset?: number;
}
// --- Service ---
export const itemsService = {
/**
* Get all items with optional filtering and pagination.
*/
async getAllItems(filters: ItemFilters = {}) {
const { search, type, rarity, limit = 100, offset = 0 } = filters;
// Build conditions array
const conditions = [];
if (search) {
conditions.push(
or(
ilike(items.name, `%${search}%`),
ilike(items.description, `%${search}%`)
)
);
}
if (type) {
conditions.push(eq(items.type, type));
}
if (rarity) {
conditions.push(eq(items.rarity, rarity));
}
// Execute query with conditions
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [itemsList, totalResult] = await Promise.all([
DrizzleClient
.select()
.from(items)
.where(whereClause)
.limit(limit)
.offset(offset)
.orderBy(items.id),
DrizzleClient
.select({ count: count() })
.from(items)
.where(whereClause)
]);
return {
items: itemsList,
total: totalResult[0]?.count ?? 0
};
},
/**
* Get a single item by ID.
*/
async getItemById(id: number) {
return await DrizzleClient.query.items.findFirst({
where: eq(items.id, id)
});
},
/**
* Get item by name (for uniqueness checks).
*/
async getItemByName(name: string) {
return await DrizzleClient.query.items.findFirst({
where: eq(items.name, name)
});
},
/**
* Create a new item.
*/
async createItem(data: CreateItemDTO, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const [item] = await txFn.insert(items)
.values({
name: data.name,
description: data.description ?? null,
rarity: data.rarity ?? 'Common',
type: data.type,
price: data.price ?? null,
iconUrl: data.iconUrl,
imageUrl: data.imageUrl,
usageData: data.usageData ?? {},
})
.returning();
return item;
}, tx);
},
/**
* Update an existing item.
*/
async updateItem(id: number, data: UpdateItemDTO, tx?: Transaction) {
return await withTransaction(async (txFn) => {
// Build update object dynamically to support partial updates
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;
if (data.type !== undefined) updateData.type = data.type;
if (data.price !== undefined) updateData.price = data.price;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
if (data.usageData !== undefined) updateData.usageData = data.usageData;
if (Object.keys(updateData).length === 0) {
// Nothing to update, just return the existing item
return await txFn.query.items.findFirst({
where: eq(items.id, id)
});
}
const [updatedItem] = await txFn
.update(items)
.set(updateData)
.where(eq(items.id, id))
.returning();
return updatedItem;
}, tx);
},
/**
* Delete an item by ID.
*/
async deleteItem(id: number, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const [deletedItem] = await txFn
.delete(items)
.where(eq(items.id, id))
.returning();
return deletedItem;
}, tx);
},
/**
* Check if an item name is already taken (for validation).
* Optionally exclude a specific ID (for updates).
*/
async isNameTaken(name: string, excludeId?: number) {
const existing = await DrizzleClient.query.items.findFirst({
where: excludeId
? and(eq(items.name, name), sql`${items.id} != ${excludeId}`)
: eq(items.name, name)
});
return !!existing;
},
/**
* Get items for autocomplete (search by name, limited results).
*/
async getItemsAutocomplete(query: string, limit: number = 25) {
return await DrizzleClient
.select({
id: items.id,
name: items.name,
rarity: items.rarity,
iconUrl: items.iconUrl,
})
.from(items)
.where(ilike(items.name, `%${query}%`))
.limit(limit);
}
};