refactor(dashboards): migrate admin-web to @bytelyst/datastore
This commit is contained in:
parent
78cb13d9c3
commit
fc1fef9c70
@ -30,6 +30,7 @@
|
||||
"@bytelyst/auth": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/datastore": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/extraction": "workspace:*",
|
||||
"@bytelyst/logger": "workspace:*",
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { getContainer } from '@/lib/cosmos';
|
||||
import { getCollection } from '@/lib/datastore';
|
||||
import { getRequestProductId } from '@/lib/product-config';
|
||||
interface CohortRow {
|
||||
cohortWeek: string; // e.g. "2026-W05"
|
||||
@ -46,19 +46,13 @@ export async function GET(req: NextRequest) {
|
||||
const productId = getRequestProductId(req);
|
||||
// Get users created in the last N weeks
|
||||
const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10);
|
||||
const usersContainer = getContainer('users');
|
||||
const { resources: users } = await usersContainer.items
|
||||
.query<{ id: string; createdAt: string }>({
|
||||
query:
|
||||
'SELECT c.id, c.createdAt FROM c ' +
|
||||
'WHERE c.productId = @pid AND c.createdAt >= @since ' +
|
||||
'ORDER BY c.createdAt ASC',
|
||||
parameters: [
|
||||
{ name: '@pid', value: productId },
|
||||
{ name: '@since', value: sinceDate },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const usersCollection = getCollection('users', '/id');
|
||||
const users = await usersCollection.rawQuery<{ id: string; createdAt: string }>(
|
||||
'SELECT c.id, c.createdAt FROM c ' +
|
||||
'WHERE c.productId = @pid AND c.createdAt >= @since ' +
|
||||
'ORDER BY c.createdAt ASC',
|
||||
{ pid: productId, since: sinceDate }
|
||||
);
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({ cohorts: [], totalUsers: 0 });
|
||||
}
|
||||
@ -74,16 +68,11 @@ export async function GET(req: NextRequest) {
|
||||
cohortMap.get(week)!.userIds.push(u.id);
|
||||
}
|
||||
// Get all usage records for these users
|
||||
const usageContainer = getContainer('usage_daily');
|
||||
const { resources: usageRecords } = await usageContainer.items
|
||||
.query<{ userId: string; date: string }>({
|
||||
query: 'SELECT c.userId, c.date FROM c ' + 'WHERE c.productId = @pid AND c.date >= @since',
|
||||
parameters: [
|
||||
{ name: '@pid', value: productId },
|
||||
{ name: '@since', value: sinceDate },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const usageCollection = getCollection('usage_daily', '/userId');
|
||||
const usageRecords = await usageCollection.rawQuery<{ userId: string; date: string }>(
|
||||
'SELECT c.userId, c.date FROM c WHERE c.productId = @pid AND c.date >= @since',
|
||||
{ pid: productId, since: sinceDate }
|
||||
);
|
||||
// Build a set of (userId, date) for quick lookup
|
||||
const userActiveDates = new Map<string, Set<string>>();
|
||||
for (const r of usageRecords) {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { getContainer } from '@/lib/cosmos';
|
||||
import { getCollection } from '@/lib/datastore';
|
||||
import { getRequestProductId } from '@/lib/product-config';
|
||||
|
||||
interface MonthlyRevenue {
|
||||
@ -45,56 +45,41 @@ export async function GET(req: NextRequest) {
|
||||
const sinceDate = new Date(now.getFullYear(), now.getMonth() - months, 1).toISOString();
|
||||
|
||||
// ---- Active subscriptions for MRR ----
|
||||
const subsContainer = getContainer('subscriptions');
|
||||
const { resources: activeSubs } = await subsContainer.items
|
||||
.query<{ id: string; plan: string; price: number; status: string; createdAt: string }>({
|
||||
query:
|
||||
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' +
|
||||
"WHERE c.productId = @pid AND c.status = 'active'",
|
||||
parameters: [{ name: '@pid', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
const subsCollection = getCollection('subscriptions', '/userId');
|
||||
const activeSubs = await subsCollection.rawQuery<{
|
||||
id: string;
|
||||
plan: string;
|
||||
price: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>(
|
||||
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' +
|
||||
"WHERE c.productId = @pid AND c.status = 'active'",
|
||||
{ pid: productId }
|
||||
);
|
||||
|
||||
// ---- Canceled subscriptions (last N months) ----
|
||||
const { resources: canceledSubs } = await subsContainer.items
|
||||
.query<{ id: string; canceledAt: string }>({
|
||||
query:
|
||||
'SELECT c.id, c.canceledAt FROM c ' +
|
||||
"WHERE c.productId = @pid AND c.status = 'canceled' " +
|
||||
'AND c.canceledAt >= @since',
|
||||
parameters: [
|
||||
{ name: '@pid', value: productId },
|
||||
{ name: '@since', value: sinceDate },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const canceledSubs = await subsCollection.rawQuery<{ id: string; canceledAt: string }>(
|
||||
'SELECT c.id, c.canceledAt FROM c ' +
|
||||
"WHERE c.productId = @pid AND c.status = 'canceled' " +
|
||||
'AND c.canceledAt >= @since',
|
||||
{ pid: productId, since: sinceDate }
|
||||
);
|
||||
|
||||
// ---- New subscriptions (last N months) ----
|
||||
const { resources: newSubs } = await subsContainer.items
|
||||
.query<{ id: string; createdAt: string }>({
|
||||
query:
|
||||
'SELECT c.id, c.createdAt FROM c ' + 'WHERE c.productId = @pid AND c.createdAt >= @since',
|
||||
parameters: [
|
||||
{ name: '@pid', value: productId },
|
||||
{ name: '@since', value: sinceDate },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const newSubs = await subsCollection.rawQuery<{ id: string; createdAt: string }>(
|
||||
'SELECT c.id, c.createdAt FROM c WHERE c.productId = @pid AND c.createdAt >= @since',
|
||||
{ pid: productId, since: sinceDate }
|
||||
);
|
||||
|
||||
// ---- Payments (last N months) ----
|
||||
const paymentsContainer = getContainer('payments');
|
||||
const { resources: payments } = await paymentsContainer.items
|
||||
.query<{ amount: number; paidAt: string }>({
|
||||
query:
|
||||
'SELECT c.amount, c.paidAt FROM c ' +
|
||||
"WHERE c.productId = @pid AND c.status = 'succeeded' " +
|
||||
'AND c.paidAt >= @since',
|
||||
parameters: [
|
||||
{ name: '@pid', value: productId },
|
||||
{ name: '@since', value: sinceDate },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const paymentsCollection = getCollection('payments', '/userId');
|
||||
const payments = await paymentsCollection.rawQuery<{ amount: number; paidAt: string }>(
|
||||
'SELECT c.amount, c.paidAt FROM c ' +
|
||||
"WHERE c.productId = @pid AND c.status = 'succeeded' " +
|
||||
'AND c.paidAt >= @since',
|
||||
{ pid: productId, since: sinceDate }
|
||||
);
|
||||
|
||||
// ---- Compute metrics ----
|
||||
const mrr = activeSubs.reduce((sum, s) => sum + (s.price ?? 0), 0);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/lib/cosmos';
|
||||
import { isDatastoreHealthy } from '@/lib/datastore';
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
@ -25,15 +25,18 @@ function checkEnvVars(): Check {
|
||||
return { name: 'env', status: 'pass', message: `${REQUIRED_ENV.length} required vars set` };
|
||||
}
|
||||
|
||||
async function checkCosmos(): Promise<Check> {
|
||||
async function checkDatastore(): Promise<Check> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const db = getDatabase();
|
||||
await db.read();
|
||||
return { name: 'cosmos', status: 'pass', latencyMs: Date.now() - start };
|
||||
const healthy = await isDatastoreHealthy();
|
||||
return {
|
||||
name: 'datastore',
|
||||
status: healthy ? 'pass' : 'fail',
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
name: 'cosmos',
|
||||
name: 'datastore',
|
||||
status: 'fail',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
latencyMs: Date.now() - start,
|
||||
@ -45,7 +48,7 @@ export async function GET() {
|
||||
const checks: Check[] = [];
|
||||
|
||||
checks.push(checkEnvVars());
|
||||
checks.push(await checkCosmos());
|
||||
checks.push(await checkDatastore());
|
||||
|
||||
const overall = checks.every(c => c.status === 'pass') ? 'ok' : 'degraded';
|
||||
|
||||
|
||||
@ -8,9 +8,16 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getContainer } from '@/lib/cosmos';
|
||||
import { getCollection } from '@/lib/datastore';
|
||||
import { requireAdmin } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
import type { DocumentCollection } from '@bytelyst/datastore';
|
||||
|
||||
// Settings docs use userId as partition key and don't have a productId field
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function settingsCollection(): DocumentCollection<any> {
|
||||
return getCollection('settings', '/userId');
|
||||
}
|
||||
|
||||
const SETTINGS_ID = 'platform_config';
|
||||
const PARTITION_KEY = '_system';
|
||||
@ -78,13 +85,8 @@ export async function GET(req: NextRequest) {
|
||||
const admin = await requireAdmin(req);
|
||||
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const container = getContainer('settings');
|
||||
try {
|
||||
const { resource } = await container.item(SETTINGS_ID, PARTITION_KEY).read<PlatformSettings>();
|
||||
if (resource) return NextResponse.json(resource);
|
||||
} catch {
|
||||
// Not found — return defaults
|
||||
}
|
||||
const settings = await settingsCollection().findById(SETTINGS_ID, PARTITION_KEY);
|
||||
if (settings) return NextResponse.json(settings);
|
||||
return NextResponse.json(DEFAULTS);
|
||||
} catch (error) {
|
||||
logError('Platform settings GET error', error);
|
||||
@ -98,15 +100,9 @@ export async function PUT(req: NextRequest) {
|
||||
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const container = getContainer('settings');
|
||||
|
||||
let existing: PlatformSettings = DEFAULTS;
|
||||
try {
|
||||
const { resource } = await container.item(SETTINGS_ID, PARTITION_KEY).read<PlatformSettings>();
|
||||
if (resource) existing = resource;
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
const existingDoc = await settingsCollection().findById(SETTINGS_ID, PARTITION_KEY);
|
||||
const existing: PlatformSettings = (existingDoc as PlatformSettings) ?? DEFAULTS;
|
||||
|
||||
const updated: PlatformSettings = {
|
||||
...existing,
|
||||
@ -124,7 +120,7 @@ export async function PUT(req: NextRequest) {
|
||||
updatedBy: admin.email ?? 'admin',
|
||||
};
|
||||
|
||||
await container.items.upsert(updated);
|
||||
await settingsCollection().upsert(updated);
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
logError('Platform settings PUT error', error);
|
||||
|
||||
@ -1,20 +1,11 @@
|
||||
/**
|
||||
* Azure Cosmos DB client for admin dashboard.
|
||||
* Uses @bytelyst/cosmos shared package with container registry.
|
||||
* Azure Cosmos DB container initialization for admin dashboard.
|
||||
*
|
||||
* Usage:
|
||||
* import { getContainer } from "@/lib/cosmos";
|
||||
* const users = getContainer("users");
|
||||
* const { resource } = await users.item(id, id).read();
|
||||
* Data access has moved to @/lib/datastore (cloud-agnostic).
|
||||
* This file is kept only for the seed route's initializeAllContainers().
|
||||
*/
|
||||
|
||||
import {
|
||||
getCosmosClient,
|
||||
getDatabase,
|
||||
registerContainers,
|
||||
getRegisteredContainer,
|
||||
initializeAllContainers,
|
||||
} from '@bytelyst/cosmos';
|
||||
import { registerContainers, initializeAllContainers } from '@bytelyst/cosmos';
|
||||
import type { ContainerConfig } from '@bytelyst/cosmos';
|
||||
|
||||
// Container definitions: name → partition key + optional TTL
|
||||
@ -37,12 +28,4 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
// Register on module load
|
||||
registerContainers(CONTAINER_DEFS);
|
||||
|
||||
/**
|
||||
* Get a container proxy by name.
|
||||
* Containers must be pre-created in Azure (or via the seed script).
|
||||
*/
|
||||
export function getContainer(name: string) {
|
||||
return getRegisteredContainer(name);
|
||||
}
|
||||
|
||||
export { getCosmosClient, getDatabase, initializeAllContainers };
|
||||
export { initializeAllContainers };
|
||||
|
||||
61
dashboards/admin-web/src/lib/datastore.ts
Normal file
61
dashboards/admin-web/src/lib/datastore.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Cloud-agnostic datastore bridge for admin dashboard.
|
||||
*
|
||||
* Wraps @bytelyst/datastore with admin-web container registry config.
|
||||
* Repositories import getCollection() from here instead of getContainer() from cosmos.
|
||||
*
|
||||
* Migration: Replace `import { getContainer } from '@/lib/cosmos'`
|
||||
* with `import { getCollection } from '@/lib/datastore'`
|
||||
*/
|
||||
|
||||
import {
|
||||
type DatastoreProvider,
|
||||
type DocumentCollection,
|
||||
type BaseDocument,
|
||||
setDatastore,
|
||||
CosmosDatastoreProvider,
|
||||
MemoryDatastoreProvider,
|
||||
} from '@bytelyst/datastore';
|
||||
|
||||
let _provider: DatastoreProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the datastore provider.
|
||||
* Auto-initializes on first getCollection() call.
|
||||
*/
|
||||
export function initDatastore(): DatastoreProvider {
|
||||
if (_provider) return _provider;
|
||||
|
||||
const dbProvider = (process.env.DB_PROVIDER || 'cosmos').toLowerCase();
|
||||
|
||||
if (dbProvider === 'memory') {
|
||||
_provider = new MemoryDatastoreProvider();
|
||||
} else {
|
||||
_provider = new CosmosDatastoreProvider();
|
||||
}
|
||||
|
||||
setDatastore(_provider);
|
||||
return _provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a typed collection from the datastore.
|
||||
* Drop-in replacement for getContainer() — returns a DocumentCollection.
|
||||
*/
|
||||
export function getCollection<T extends BaseDocument = BaseDocument>(
|
||||
name: string,
|
||||
partitionKeyPath: string = '/id'
|
||||
): DocumentCollection<T> {
|
||||
if (!_provider) {
|
||||
initDatastore();
|
||||
}
|
||||
return _provider!.getCollection<T>(name, partitionKeyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check datastore health.
|
||||
*/
|
||||
export async function isDatastoreHealthy(): Promise<boolean> {
|
||||
if (!_provider) initDatastore();
|
||||
return _provider!.isHealthy();
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
/** Theme repository for Cosmos DB operations. */
|
||||
/** Theme repository for cloud-agnostic datastore operations. */
|
||||
|
||||
import { getContainer } from '@/lib/cosmos';
|
||||
import { getCollection } from '@/lib/datastore';
|
||||
import { Theme, ThemeCreate, ThemeUpdate } from '@/types/theme';
|
||||
import type { DocumentCollection } from '@bytelyst/datastore';
|
||||
|
||||
function container() {
|
||||
return getContainer('themes');
|
||||
// Theme docs use id as partition key and don't have a productId field
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function collection(): DocumentCollection<any> {
|
||||
return getCollection('themes', '/id');
|
||||
}
|
||||
|
||||
export class ThemeRepository {
|
||||
@ -26,35 +29,28 @@ export class ThemeRepository {
|
||||
type: 'theme',
|
||||
};
|
||||
|
||||
const { resource } = await container().items.create(newTheme);
|
||||
const resource = await collection().create(newTheme);
|
||||
return resource as Theme;
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Theme | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read();
|
||||
return resource;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return collection().findById(id, id);
|
||||
}
|
||||
|
||||
async getActive(): Promise<Theme | null> {
|
||||
const query = `
|
||||
SELECT * FROM c
|
||||
WHERE c.type = 'theme' AND c.is_active = true
|
||||
ORDER BY c.is_default DESC, c.created_at DESC
|
||||
OFFSET 0 LIMIT 1
|
||||
`;
|
||||
|
||||
const { resources } = await container().items.query(query).fetchAll();
|
||||
return resources[0] || null;
|
||||
const results = await collection().rawQuery<Theme>(
|
||||
`SELECT * FROM c
|
||||
WHERE c.type = 'theme' AND c.is_active = true
|
||||
ORDER BY c.is_default DESC, c.created_at DESC
|
||||
OFFSET 0 LIMIT 1`
|
||||
);
|
||||
return results[0] || null;
|
||||
}
|
||||
|
||||
async getAll(): Promise<Theme[]> {
|
||||
const query = "SELECT * FROM c WHERE c.type = 'theme' ORDER BY c.created_at DESC";
|
||||
const { resources } = await container().items.query(query).fetchAll();
|
||||
return resources;
|
||||
return collection().rawQuery<Theme>(
|
||||
"SELECT * FROM c WHERE c.type = 'theme' ORDER BY c.created_at DESC"
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: string, update: ThemeUpdate): Promise<Theme | null> {
|
||||
@ -72,7 +68,7 @@ export class ThemeRepository {
|
||||
await this.deactivateAllOthers(id);
|
||||
}
|
||||
|
||||
const { resource } = await container().item(id, id).replace(updatedTheme);
|
||||
const resource = await collection().update(id, id, updatedTheme);
|
||||
return resource as Theme;
|
||||
}
|
||||
|
||||
@ -90,7 +86,7 @@ export class ThemeRepository {
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -98,23 +94,16 @@ export class ThemeRepository {
|
||||
}
|
||||
|
||||
private async deactivateAllOthers(excludeId: string): Promise<void> {
|
||||
const query = `
|
||||
SELECT * FROM c
|
||||
WHERE c.type = 'theme' AND c.id != @excludeId AND c.is_active = true
|
||||
`;
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query({ query, parameters: [{ name: '@excludeId', value: excludeId }] })
|
||||
.fetchAll();
|
||||
const resources = await collection().rawQuery<Theme>(
|
||||
'SELECT * FROM c WHERE c.type = @type AND c.id != @excludeId AND c.is_active = true',
|
||||
{ type: 'theme', excludeId }
|
||||
);
|
||||
|
||||
for (const item of resources) {
|
||||
await container()
|
||||
.item(item.id, item.id)
|
||||
.replace({
|
||||
...item,
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
await collection().update(item.id, item.id, {
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
/**
|
||||
* API token repository — direct Cosmos DB CRUD operations.
|
||||
* API token repository — cloud-agnostic datastore operations.
|
||||
*/
|
||||
|
||||
import { SqlQuerySpec } from '@azure/cosmos';
|
||||
import { getContainer } from '@/lib/cosmos';
|
||||
import { getCollection } from '@/lib/datastore';
|
||||
import { PRODUCT_ID } from '@/lib/product-config';
|
||||
import type { BaseDocument, DocumentCollection } from '@bytelyst/datastore';
|
||||
|
||||
export interface ApiTokenDoc {
|
||||
id: string;
|
||||
productId: string; // multi-tenancy key from @bytelyst/config
|
||||
export interface ApiTokenDoc extends BaseDocument {
|
||||
userId: string;
|
||||
userName: string;
|
||||
name: string;
|
||||
@ -24,8 +22,8 @@ export interface ApiTokenDoc {
|
||||
|
||||
export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>;
|
||||
|
||||
function container() {
|
||||
return getContainer('api_tokens');
|
||||
function collection(): DocumentCollection<ApiTokenDoc> {
|
||||
return getCollection<ApiTokenDoc>('api_tokens', '/userId');
|
||||
}
|
||||
|
||||
function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
|
||||
@ -35,24 +33,15 @@ function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
|
||||
}
|
||||
|
||||
export async function getTokenById(id: string, userId: string): Promise<ApiTokenDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<ApiTokenDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return collection().findById(id, userId);
|
||||
}
|
||||
|
||||
export async function listTokens(limit = 100, productId = PRODUCT_ID): Promise<ApiTokenResponse[]> {
|
||||
const query: SqlQuerySpec = {
|
||||
query:
|
||||
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit",
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
};
|
||||
const { resources } = await container().items.query<ApiTokenDoc>(query).fetchAll();
|
||||
const resources = await collection().findMany({
|
||||
filter: { productId, status: { $ne: 'expired' } },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
return resources.map(stripHash);
|
||||
}
|
||||
|
||||
@ -60,39 +49,29 @@ export async function listTokensByUser(
|
||||
userId: string,
|
||||
productId = PRODUCT_ID
|
||||
): Promise<ApiTokenResponse[]> {
|
||||
const query: SqlQuerySpec = {
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@userId', value: userId },
|
||||
],
|
||||
};
|
||||
const { resources } = await container().items.query<ApiTokenDoc>(query).fetchAll();
|
||||
const resources = await collection().findMany({
|
||||
filter: { productId, userId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
return resources.map(stripHash);
|
||||
}
|
||||
|
||||
export async function createToken(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
|
||||
const { resource } = await container().items.create<ApiTokenDoc>(doc);
|
||||
return stripHash(resource!);
|
||||
const resource = await collection().create(doc);
|
||||
return stripHash(resource);
|
||||
}
|
||||
|
||||
export async function revokeToken(id: string, userId: string): Promise<boolean> {
|
||||
const existing = await getTokenById(id, userId);
|
||||
if (!existing) return false;
|
||||
|
||||
await container()
|
||||
.item(id, userId)
|
||||
.replace<ApiTokenDoc>({
|
||||
...existing,
|
||||
status: 'revoked',
|
||||
});
|
||||
await collection().update(id, userId, { status: 'revoked' } as Partial<ApiTokenDoc>);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteToken(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, userId).delete();
|
||||
await collection().delete(id, userId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -100,7 +79,5 @@ export async function deleteToken(id: string, userId: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export async function countActiveTokens(productId = PRODUCT_ID): Promise<number> {
|
||||
const query = `SELECT VALUE COUNT(1) FROM c WHERE c.productId = '${productId}' AND c.status = 'active'`;
|
||||
const { resources } = await container().items.query<number>(query).fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
return collection().count({ productId, status: 'active' });
|
||||
}
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
/**
|
||||
* User repository — direct Cosmos DB CRUD operations.
|
||||
* User repository — cloud-agnostic datastore operations.
|
||||
*/
|
||||
|
||||
import { SqlQuerySpec } from '@azure/cosmos';
|
||||
import { getContainer } from '@/lib/cosmos';
|
||||
import { getCollection } from '@/lib/datastore';
|
||||
import { PRODUCT_ID } from '@/lib/product-config';
|
||||
import type { BaseDocument, DocumentCollection } from '@bytelyst/datastore';
|
||||
|
||||
export interface UserDoc {
|
||||
id: string;
|
||||
productId: string; // multi-tenancy key from @bytelyst/config
|
||||
export interface UserDoc extends BaseDocument {
|
||||
email: string;
|
||||
name: string;
|
||||
passwordHash: string;
|
||||
@ -24,8 +22,8 @@ export interface UserDoc {
|
||||
|
||||
export type UserResponse = Omit<UserDoc, 'passwordHash'>;
|
||||
|
||||
function container() {
|
||||
return getContainer('users');
|
||||
function collection(): DocumentCollection<UserDoc> {
|
||||
return getCollection<UserDoc>('users', '/id');
|
||||
}
|
||||
|
||||
function stripPassword(doc: UserDoc): UserResponse {
|
||||
@ -35,27 +33,16 @@ function stripPassword(doc: UserDoc): UserResponse {
|
||||
}
|
||||
|
||||
export async function getUserById(id: string): Promise<UserDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return collection().findById(id, id);
|
||||
}
|
||||
|
||||
export async function getUserByEmail(
|
||||
email: string,
|
||||
productId = PRODUCT_ID
|
||||
): Promise<UserDoc | null> {
|
||||
const query: SqlQuerySpec = {
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@email', value: email.toLowerCase() },
|
||||
],
|
||||
};
|
||||
const { resources } = await container().items.query<UserDoc>(query).fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { productId, email: email.toLowerCase() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
@ -63,25 +50,21 @@ export async function listUsers(
|
||||
offset = 0,
|
||||
productId = PRODUCT_ID
|
||||
): Promise<UserResponse[]> {
|
||||
const query: SqlQuerySpec = {
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@offset', value: offset },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
};
|
||||
const { resources } = await container().items.query<UserDoc>(query).fetchAll();
|
||||
const resources = await collection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
return resources.map(stripPassword);
|
||||
}
|
||||
|
||||
export async function createUser(doc: UserDoc): Promise<UserResponse> {
|
||||
const { resource } = await container().items.create<UserDoc>({
|
||||
const resource = await collection().create({
|
||||
...doc,
|
||||
email: doc.email.toLowerCase(),
|
||||
});
|
||||
return stripPassword(resource!);
|
||||
return stripPassword(resource);
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
@ -91,14 +74,16 @@ export async function updateUser(
|
||||
const existing = await getUserById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const updated = { ...existing, ...updates, lastActive: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, id).replace<UserDoc>(updated);
|
||||
return stripPassword(resource!);
|
||||
const updated = await collection().update(id, id, {
|
||||
...updates,
|
||||
lastActive: new Date().toISOString(),
|
||||
});
|
||||
return stripPassword(updated);
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -106,20 +91,15 @@ export async function deleteUser(id: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function countUsers(productId = PRODUCT_ID): Promise<number> {
|
||||
const { resources } = await container()
|
||||
.items.query<number>({
|
||||
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
return collection().count({ productId });
|
||||
}
|
||||
|
||||
export async function countUsersByPlan(productId = PRODUCT_ID): Promise<Record<string, number>> {
|
||||
const query = `SELECT c.plan, COUNT(1) AS count FROM c WHERE c.productId = '${productId}' GROUP BY c.plan`;
|
||||
const { resources } = await container()
|
||||
.items.query<{ plan: string; count: number }>(query)
|
||||
.fetchAll();
|
||||
const resources = await collection().aggregate<{ plan: string; count: number }>({
|
||||
groupBy: 'plan',
|
||||
aggregations: [{ field: 'id', op: 'count', alias: 'count' }],
|
||||
filter: { productId },
|
||||
});
|
||||
const result: Record<string, number> = { free: 0, pro: 0, enterprise: 0 };
|
||||
for (const r of resources) {
|
||||
result[r.plan] = r.count;
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -89,6 +89,9 @@ importers:
|
||||
'@bytelyst/cosmos':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/cosmos
|
||||
'@bytelyst/datastore':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/datastore
|
||||
'@bytelyst/errors':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/errors
|
||||
|
||||
Loading…
Reference in New Issue
Block a user