feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
All checks were successful
Deploy to Production / test (push) Successful in 44s

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

102
shared/lib/assets.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Asset URL Resolution Utilities
* Provides helpers for constructing full asset URLs from item IDs or relative paths.
* Works for both local development and production environments.
*/
import { env } from "./env";
/**
* Get the base URL for assets.
* In production, this should be the public dashboard URL.
* In development, it defaults to localhost with the configured port.
*/
function getAssetsBaseUrl(): string {
// Check for explicitly configured asset URL first
if (process.env.ASSETS_BASE_URL) {
return process.env.ASSETS_BASE_URL.replace(/\/$/, ""); // Remove trailing slash
}
// In production, construct from HOST/PORT or use a default
if (process.env.NODE_ENV === "production") {
// If WEB_URL is set, use it (future-proofing)
if (process.env.WEB_URL) {
return process.env.WEB_URL.replace(/\/$/, "");
}
// Fallback: use the configured host and port
const host = env.HOST === "0.0.0.0" ? "localhost" : env.HOST;
return `http://${host}:${env.PORT}`;
}
// Development: use localhost with the configured port
return `http://localhost:${env.PORT}`;
}
/**
* Get the full URL for an item's icon image.
* @param itemId - The item's database ID
* @returns Full URL to the item's icon image
*
* @example
* getItemIconUrl(42) // => "http://localhost:3000/assets/items/42.png"
*/
export function getItemIconUrl(itemId: number): string {
return `${getAssetsBaseUrl()}/assets/items/${itemId}.png`;
}
/**
* Get the full URL for any asset given its relative path.
* @param relativePath - Path relative to the assets root (e.g., "items/42.png")
* @returns Full URL to the asset
*
* @example
* getAssetUrl("items/42.png") // => "http://localhost:3000/assets/items/42.png"
*/
export function getAssetUrl(relativePath: string): string {
const cleanPath = relativePath.replace(/^\/+/, ""); // Remove leading slashes
return `${getAssetsBaseUrl()}/assets/${cleanPath}`;
}
/**
* Check if a URL is a local asset URL (relative path starting with /assets/).
* @param url - The URL to check
* @returns True if it's a local asset reference
*/
export function isLocalAssetUrl(url: string | null | undefined): boolean {
if (!url) return false;
return url.startsWith("/assets/") || url.startsWith("assets/");
}
/**
* Convert a relative asset path to a full URL.
* If the URL is already absolute (http/https), returns it unchanged.
* If it's a relative asset path, constructs the full URL.
*
* @param url - The URL to resolve (relative or absolute)
* @returns The resolved full URL, or null if input was null/undefined
*/
export function resolveAssetUrl(url: string | null | undefined): string | null {
if (!url) return null;
// Already absolute
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
// Relative asset path
if (isLocalAssetUrl(url)) {
const cleanPath = url.replace(/^\/+assets\//, "").replace(/^assets\//, "");
return getAssetUrl(cleanPath);
}
// Unknown format, return as-is
return url;
}
/**
* Get the placeholder image URL for items without an uploaded image.
* @returns Full URL to the placeholder image
*/
export function getPlaceholderIconUrl(): string {
return `${getAssetsBaseUrl()}/assets/items/placeholder.png`;
}

View File

@@ -8,6 +8,9 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3000),
HOST: z.string().default("127.0.0.1"),
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(),
// Asset URL configuration (for production with custom domains)
ASSETS_BASE_URL: z.string().url().optional(),
WEB_URL: z.string().url().optional(),
});
const parsedEnv = envSchema.safeParse(process.env);

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);
}
};