feat: add sorting and pinning functionality for accounts and sites
- Introduced `isPinned` and `sortOrder` fields in the `accounts` and `sites` tables to allow users to pin and sort their accounts and sites. - Updated API routes to handle pinning and sorting operations for accounts and sites. - Enhanced the frontend to support sorting by balance and custom order, as well as toggling the pin status of accounts and sites. - Added tests for sorting and reordering logic to ensure correct functionality. - Updated documentation to reflect changes in token management and new features.
This commit is contained in:
@@ -22,9 +22,9 @@ function parseCsvList(value: string | undefined): string[] {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
authToken: process.env.AUTH_TOKEN || 'change-me',
|
||||
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy',
|
||||
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me',
|
||||
authToken: process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: process.env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
webhookUrl: process.env.WEBHOOK_URL || '',
|
||||
|
||||
@@ -7,6 +7,8 @@ export const sites = sqliteTable('sites', {
|
||||
url: text('url').notNull(),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api'
|
||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
apiKey: text('api_key'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
@@ -26,6 +28,8 @@ export const accounts = sqliteTable('accounts', {
|
||||
unitCost: real('unit_cost'),
|
||||
valueScore: real('value_score').default(0),
|
||||
status: text('status').default('active'), // 'active' | 'disabled' | 'expired'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
|
||||
lastCheckinAt: text('last_checkin_at'),
|
||||
lastBalanceRefresh: text('last_balance_refresh'),
|
||||
|
||||
@@ -22,14 +22,39 @@ type AccountWithSiteRow = {
|
||||
sites: typeof schema.sites.$inferSelect;
|
||||
};
|
||||
|
||||
type AccountHealthRefreshResult = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
siteName: string;
|
||||
type AccountHealthRefreshResult = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
siteName: string;
|
||||
status: 'success' | 'failed' | 'skipped';
|
||||
state: RuntimeHealthState;
|
||||
message: string;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
|
||||
function normalizePinnedFlag(input: unknown): boolean | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
if (typeof input === 'boolean') return input;
|
||||
if (typeof input === 'number') return input !== 0;
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(input: unknown): number | null {
|
||||
if (input === undefined || input === null || input === '') return null;
|
||||
const parsed = Number.parseInt(String(input), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function getNextAccountSortOrder(): number {
|
||||
const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
|
||||
return {
|
||||
@@ -239,16 +264,18 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||
} else {
|
||||
const created = db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
username,
|
||||
accessToken: loginResult.accessToken,
|
||||
apiToken: preferredApiToken || undefined,
|
||||
checkinEnabled: true,
|
||||
extraConfig,
|
||||
}).returning().get();
|
||||
accountId = created.id;
|
||||
}
|
||||
const created = db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
username,
|
||||
accessToken: loginResult.accessToken,
|
||||
apiToken: preferredApiToken || undefined,
|
||||
checkinEnabled: true,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
accountId = created.id;
|
||||
}
|
||||
|
||||
const result = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
|
||||
if (!result) {
|
||||
@@ -428,14 +455,16 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
extraConfig = mergeAccountExtraConfig(undefined, { platformUserId: resolvedPlatformUserId });
|
||||
}
|
||||
|
||||
const result = db.insert(schema.accounts).values({
|
||||
siteId: body.siteId,
|
||||
username: username || undefined,
|
||||
accessToken,
|
||||
apiToken: apiToken || undefined,
|
||||
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
||||
extraConfig,
|
||||
}).returning().get();
|
||||
const result = db.insert(schema.accounts).values({
|
||||
siteId: body.siteId,
|
||||
username: username || undefined,
|
||||
accessToken,
|
||||
apiToken: apiToken || undefined,
|
||||
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
|
||||
if (apiToken) {
|
||||
try {
|
||||
@@ -469,15 +498,32 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update an account
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request) => {
|
||||
const id = parseInt(request.params.id);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const updates: any = {};
|
||||
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
|
||||
if (body[key] !== undefined) updates[key] = body[key];
|
||||
}
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const updates: any = {};
|
||||
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
|
||||
if (body[key] !== undefined) updates[key] = body[key];
|
||||
}
|
||||
|
||||
if (body.isPinned !== undefined) {
|
||||
const normalizedPinned = normalizePinnedFlag(body.isPinned);
|
||||
if (normalizedPinned === null) {
|
||||
return reply.code(400).send({ message: 'Invalid isPinned value. Expected boolean.' });
|
||||
}
|
||||
updates.isPinned = normalizedPinned;
|
||||
}
|
||||
|
||||
if (body.sortOrder !== undefined) {
|
||||
const normalizedSortOrder = normalizeSortOrder(body.sortOrder);
|
||||
if (normalizedSortOrder === null) {
|
||||
return reply.code(400).send({ message: 'Invalid sortOrder value. Expected non-negative integer.' });
|
||||
}
|
||||
updates.sortOrder = normalizedSortOrder;
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
|
||||
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
try {
|
||||
|
||||
@@ -11,19 +11,60 @@ function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePinnedFlag(input: unknown): boolean | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
if (typeof input === 'boolean') return input;
|
||||
if (typeof input === 'number') return input !== 0;
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(input: unknown): number | null {
|
||||
if (input === undefined || input === null || input === '') return null;
|
||||
const parsed = Number.parseInt(String(input), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
export async function sitesRoutes(app: FastifyInstance) {
|
||||
// List all sites
|
||||
app.get('/api/sites', async () => {
|
||||
return db.select().from(schema.sites).all();
|
||||
const siteRows = db.select().from(schema.sites).all();
|
||||
const accountRows = db.select().from(schema.accounts).all();
|
||||
|
||||
const totalBalanceBySiteId: Record<number, number> = {};
|
||||
for (const account of accountRows) {
|
||||
totalBalanceBySiteId[account.siteId] = (totalBalanceBySiteId[account.siteId] || 0) + (account.balance || 0);
|
||||
}
|
||||
|
||||
return siteRows.map((site) => ({
|
||||
...site,
|
||||
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
||||
}));
|
||||
});
|
||||
|
||||
// Add a site
|
||||
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, apiKey, status } = request.body;
|
||||
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, apiKey, status, isPinned, sortOrder } = request.body;
|
||||
const normalizedStatus = normalizeSiteStatus(status);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
}
|
||||
const normalizedPinned = normalizePinnedFlag(isPinned);
|
||||
if (isPinned !== undefined && normalizedPinned === null) {
|
||||
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
||||
}
|
||||
const normalizedSortOrder = normalizeSortOrder(sortOrder);
|
||||
if (sortOrder !== undefined && normalizedSortOrder === null) {
|
||||
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
|
||||
}
|
||||
|
||||
const existingSites = db.select().from(schema.sites).all();
|
||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||
|
||||
let detectedPlatform = platform;
|
||||
if (!detectedPlatform) {
|
||||
@@ -39,12 +80,14 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
platform: detectedPlatform,
|
||||
apiKey,
|
||||
status: normalizedStatus ?? 'active',
|
||||
isPinned: normalizedPinned ?? false,
|
||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||
}).returning().get();
|
||||
return result;
|
||||
});
|
||||
|
||||
// Update a site
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
@@ -61,12 +104,22 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
}
|
||||
const normalizedPinned = normalizePinnedFlag(body.isPinned);
|
||||
if (body.isPinned !== undefined && normalizedPinned === null) {
|
||||
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
||||
}
|
||||
const normalizedSortOrder = normalizeSortOrder(body.sortOrder);
|
||||
if (body.sortOrder !== undefined && normalizedSortOrder === null) {
|
||||
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
|
||||
}
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
|
||||
if (body.status !== undefined) updates.status = normalizedStatus;
|
||||
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const VERSION = 'v1';
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function buildKey(): Buffer {
|
||||
const secret = (config.accountCredentialSecret || '').trim() || config.authToken || 'change-me';
|
||||
const secret = (config.accountCredentialSecret || '').trim() || config.authToken || 'change-me-admin-token';
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,8 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
url: siteUrl,
|
||||
platform,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: sites.length,
|
||||
apiKey: null,
|
||||
createdAt: toIsoString(item.created_at),
|
||||
updatedAt: toIsoString(item.updated_at),
|
||||
@@ -213,6 +215,8 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: asBoolean(item.disabled, false) ? 'disabled' : 'active',
|
||||
isPinned: false,
|
||||
sortOrder: accounts.length,
|
||||
checkinEnabled: asBoolean(checkin.autoCheckInEnabled, true),
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
|
||||
Reference in New Issue
Block a user