refactor(dashboards): migrate admin-web to @bytelyst/datastore

This commit is contained in:
saravanakumardb1 2026-03-02 01:49:41 -08:00
parent 78cb13d9c3
commit fc1fef9c70
11 changed files with 216 additions and 249 deletions

View File

@ -30,6 +30,7 @@
"@bytelyst/auth": "workspace:*", "@bytelyst/auth": "workspace:*",
"@bytelyst/config": "workspace:*", "@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*", "@bytelyst/cosmos": "workspace:*",
"@bytelyst/datastore": "workspace:*",
"@bytelyst/errors": "workspace:*", "@bytelyst/errors": "workspace:*",
"@bytelyst/extraction": "workspace:*", "@bytelyst/extraction": "workspace:*",
"@bytelyst/logger": "workspace:*", "@bytelyst/logger": "workspace:*",

View File

@ -9,7 +9,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server'; import { getCurrentUser } from '@/lib/auth-server';
import { getContainer } from '@/lib/cosmos'; import { getCollection } from '@/lib/datastore';
import { getRequestProductId } from '@/lib/product-config'; import { getRequestProductId } from '@/lib/product-config';
interface CohortRow { interface CohortRow {
cohortWeek: string; // e.g. "2026-W05" cohortWeek: string; // e.g. "2026-W05"
@ -46,19 +46,13 @@ export async function GET(req: NextRequest) {
const productId = getRequestProductId(req); const productId = getRequestProductId(req);
// Get users created in the last N weeks // Get users created in the last N weeks
const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10); const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10);
const usersContainer = getContainer('users'); const usersCollection = getCollection('users', '/id');
const { resources: users } = await usersContainer.items const users = await usersCollection.rawQuery<{ id: string; createdAt: string }>(
.query<{ id: string; createdAt: string }>({ 'SELECT c.id, c.createdAt FROM c ' +
query: 'WHERE c.productId = @pid AND c.createdAt >= @since ' +
'SELECT c.id, c.createdAt FROM c ' + 'ORDER BY c.createdAt ASC',
'WHERE c.productId = @pid AND c.createdAt >= @since ' + { pid: productId, since: sinceDate }
'ORDER BY c.createdAt ASC', );
parameters: [
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
if (users.length === 0) { if (users.length === 0) {
return NextResponse.json({ cohorts: [], totalUsers: 0 }); return NextResponse.json({ cohorts: [], totalUsers: 0 });
} }
@ -74,16 +68,11 @@ export async function GET(req: NextRequest) {
cohortMap.get(week)!.userIds.push(u.id); cohortMap.get(week)!.userIds.push(u.id);
} }
// Get all usage records for these users // Get all usage records for these users
const usageContainer = getContainer('usage_daily'); const usageCollection = getCollection('usage_daily', '/userId');
const { resources: usageRecords } = await usageContainer.items const usageRecords = await usageCollection.rawQuery<{ userId: string; date: string }>(
.query<{ userId: string; date: string }>({ 'SELECT c.userId, c.date FROM c WHERE c.productId = @pid AND c.date >= @since',
query: 'SELECT c.userId, c.date FROM c ' + 'WHERE c.productId = @pid AND c.date >= @since', { pid: productId, since: sinceDate }
parameters: [ );
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// Build a set of (userId, date) for quick lookup // Build a set of (userId, date) for quick lookup
const userActiveDates = new Map<string, Set<string>>(); const userActiveDates = new Map<string, Set<string>>();
for (const r of usageRecords) { for (const r of usageRecords) {

View File

@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server'; import { getCurrentUser } from '@/lib/auth-server';
import { getContainer } from '@/lib/cosmos'; import { getCollection } from '@/lib/datastore';
import { getRequestProductId } from '@/lib/product-config'; import { getRequestProductId } from '@/lib/product-config';
interface MonthlyRevenue { interface MonthlyRevenue {
@ -45,56 +45,41 @@ export async function GET(req: NextRequest) {
const sinceDate = new Date(now.getFullYear(), now.getMonth() - months, 1).toISOString(); const sinceDate = new Date(now.getFullYear(), now.getMonth() - months, 1).toISOString();
// ---- Active subscriptions for MRR ---- // ---- Active subscriptions for MRR ----
const subsContainer = getContainer('subscriptions'); const subsCollection = getCollection('subscriptions', '/userId');
const { resources: activeSubs } = await subsContainer.items const activeSubs = await subsCollection.rawQuery<{
.query<{ id: string; plan: string; price: number; status: string; createdAt: string }>({ id: string;
query: plan: string;
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' + price: number;
"WHERE c.productId = @pid AND c.status = 'active'", status: string;
parameters: [{ name: '@pid', value: productId }], createdAt: string;
}) }>(
.fetchAll(); '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) ---- // ---- Canceled subscriptions (last N months) ----
const { resources: canceledSubs } = await subsContainer.items const canceledSubs = await subsCollection.rawQuery<{ id: string; canceledAt: string }>(
.query<{ id: string; canceledAt: string }>({ 'SELECT c.id, c.canceledAt FROM c ' +
query: "WHERE c.productId = @pid AND c.status = 'canceled' " +
'SELECT c.id, c.canceledAt FROM c ' + 'AND c.canceledAt >= @since',
"WHERE c.productId = @pid AND c.status = 'canceled' " + { pid: productId, since: sinceDate }
'AND c.canceledAt >= @since', );
parameters: [
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// ---- New subscriptions (last N months) ---- // ---- New subscriptions (last N months) ----
const { resources: newSubs } = await subsContainer.items const newSubs = await subsCollection.rawQuery<{ id: string; createdAt: string }>(
.query<{ id: string; createdAt: string }>({ 'SELECT c.id, c.createdAt FROM c WHERE c.productId = @pid AND c.createdAt >= @since',
query: { pid: productId, since: sinceDate }
'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();
// ---- Payments (last N months) ---- // ---- Payments (last N months) ----
const paymentsContainer = getContainer('payments'); const paymentsCollection = getCollection('payments', '/userId');
const { resources: payments } = await paymentsContainer.items const payments = await paymentsCollection.rawQuery<{ amount: number; paidAt: string }>(
.query<{ amount: number; paidAt: string }>({ 'SELECT c.amount, c.paidAt FROM c ' +
query: "WHERE c.productId = @pid AND c.status = 'succeeded' " +
'SELECT c.amount, c.paidAt FROM c ' + 'AND c.paidAt >= @since',
"WHERE c.productId = @pid AND c.status = 'succeeded' " + { pid: productId, since: sinceDate }
'AND c.paidAt >= @since', );
parameters: [
{ name: '@pid', value: productId },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// ---- Compute metrics ---- // ---- Compute metrics ----
const mrr = activeSubs.reduce((sum, s) => sum + (s.price ?? 0), 0); const mrr = activeSubs.reduce((sum, s) => sum + (s.price ?? 0), 0);

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '@/lib/cosmos'; import { isDatastoreHealthy } from '@/lib/datastore';
interface Check { interface Check {
name: string; name: string;
@ -25,15 +25,18 @@ function checkEnvVars(): Check {
return { name: 'env', status: 'pass', message: `${REQUIRED_ENV.length} required vars set` }; 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(); const start = Date.now();
try { try {
const db = getDatabase(); const healthy = await isDatastoreHealthy();
await db.read(); return {
return { name: 'cosmos', status: 'pass', latencyMs: Date.now() - start }; name: 'datastore',
status: healthy ? 'pass' : 'fail',
latencyMs: Date.now() - start,
};
} catch (err) { } catch (err) {
return { return {
name: 'cosmos', name: 'datastore',
status: 'fail', status: 'fail',
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
latencyMs: Date.now() - start, latencyMs: Date.now() - start,
@ -45,7 +48,7 @@ export async function GET() {
const checks: Check[] = []; const checks: Check[] = [];
checks.push(checkEnvVars()); checks.push(checkEnvVars());
checks.push(await checkCosmos()); checks.push(await checkDatastore());
const overall = checks.every(c => c.status === 'pass') ? 'ok' : 'degraded'; const overall = checks.every(c => c.status === 'pass') ? 'ok' : 'degraded';

View File

@ -8,9 +8,16 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getContainer } from '@/lib/cosmos'; import { getCollection } from '@/lib/datastore';
import { requireAdmin } from '@/lib/auth-server'; import { requireAdmin } from '@/lib/auth-server';
import { logError } from '@/lib/logger'; 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 SETTINGS_ID = 'platform_config';
const PARTITION_KEY = '_system'; const PARTITION_KEY = '_system';
@ -78,13 +85,8 @@ export async function GET(req: NextRequest) {
const admin = await requireAdmin(req); const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const container = getContainer('settings'); const settings = await settingsCollection().findById(SETTINGS_ID, PARTITION_KEY);
try { if (settings) return NextResponse.json(settings);
const { resource } = await container.item(SETTINGS_ID, PARTITION_KEY).read<PlatformSettings>();
if (resource) return NextResponse.json(resource);
} catch {
// Not found — return defaults
}
return NextResponse.json(DEFAULTS); return NextResponse.json(DEFAULTS);
} catch (error) { } catch (error) {
logError('Platform settings GET error', 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 }); if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json(); const body = await req.json();
const container = getContainer('settings');
let existing: PlatformSettings = DEFAULTS; const existingDoc = await settingsCollection().findById(SETTINGS_ID, PARTITION_KEY);
try { const existing: PlatformSettings = (existingDoc as PlatformSettings) ?? DEFAULTS;
const { resource } = await container.item(SETTINGS_ID, PARTITION_KEY).read<PlatformSettings>();
if (resource) existing = resource;
} catch {
// Use defaults
}
const updated: PlatformSettings = { const updated: PlatformSettings = {
...existing, ...existing,
@ -124,7 +120,7 @@ export async function PUT(req: NextRequest) {
updatedBy: admin.email ?? 'admin', updatedBy: admin.email ?? 'admin',
}; };
await container.items.upsert(updated); await settingsCollection().upsert(updated);
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
logError('Platform settings PUT error', error); logError('Platform settings PUT error', error);

View File

@ -1,20 +1,11 @@
/** /**
* Azure Cosmos DB client for admin dashboard. * Azure Cosmos DB container initialization for admin dashboard.
* Uses @bytelyst/cosmos shared package with container registry.
* *
* Usage: * Data access has moved to @/lib/datastore (cloud-agnostic).
* import { getContainer } from "@/lib/cosmos"; * This file is kept only for the seed route's initializeAllContainers().
* const users = getContainer("users");
* const { resource } = await users.item(id, id).read();
*/ */
import { import { registerContainers, initializeAllContainers } from '@bytelyst/cosmos';
getCosmosClient,
getDatabase,
registerContainers,
getRegisteredContainer,
initializeAllContainers,
} from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos'; import type { ContainerConfig } from '@bytelyst/cosmos';
// Container definitions: name → partition key + optional TTL // Container definitions: name → partition key + optional TTL
@ -37,12 +28,4 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
// Register on module load // Register on module load
registerContainers(CONTAINER_DEFS); registerContainers(CONTAINER_DEFS);
/** export { initializeAllContainers };
* 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 };

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

View File

@ -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 { Theme, ThemeCreate, ThemeUpdate } from '@/types/theme';
import type { DocumentCollection } from '@bytelyst/datastore';
function container() { // Theme docs use id as partition key and don't have a productId field
return getContainer('themes'); // eslint-disable-next-line @typescript-eslint/no-explicit-any
function collection(): DocumentCollection<any> {
return getCollection('themes', '/id');
} }
export class ThemeRepository { export class ThemeRepository {
@ -26,35 +29,28 @@ export class ThemeRepository {
type: 'theme', type: 'theme',
}; };
const { resource } = await container().items.create(newTheme); const resource = await collection().create(newTheme);
return resource as Theme; return resource as Theme;
} }
async getById(id: string): Promise<Theme | null> { async getById(id: string): Promise<Theme | null> {
try { return collection().findById(id, id);
const { resource } = await container().item(id, id).read();
return resource;
} catch {
return null;
}
} }
async getActive(): Promise<Theme | null> { async getActive(): Promise<Theme | null> {
const query = ` const results = await collection().rawQuery<Theme>(
SELECT * FROM c `SELECT * FROM c
WHERE c.type = 'theme' AND c.is_active = true WHERE c.type = 'theme' AND c.is_active = true
ORDER BY c.is_default DESC, c.created_at DESC ORDER BY c.is_default DESC, c.created_at DESC
OFFSET 0 LIMIT 1 OFFSET 0 LIMIT 1`
`; );
return results[0] || null;
const { resources } = await container().items.query(query).fetchAll();
return resources[0] || null;
} }
async getAll(): Promise<Theme[]> { async getAll(): Promise<Theme[]> {
const query = "SELECT * FROM c WHERE c.type = 'theme' ORDER BY c.created_at DESC"; return collection().rawQuery<Theme>(
const { resources } = await container().items.query(query).fetchAll(); "SELECT * FROM c WHERE c.type = 'theme' ORDER BY c.created_at DESC"
return resources; );
} }
async update(id: string, update: ThemeUpdate): Promise<Theme | null> { async update(id: string, update: ThemeUpdate): Promise<Theme | null> {
@ -72,7 +68,7 @@ export class ThemeRepository {
await this.deactivateAllOthers(id); 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; return resource as Theme;
} }
@ -90,7 +86,7 @@ export class ThemeRepository {
async delete(id: string): Promise<boolean> { async delete(id: string): Promise<boolean> {
try { try {
await container().item(id, id).delete(); await collection().delete(id, id);
return true; return true;
} catch { } catch {
return false; return false;
@ -98,23 +94,16 @@ export class ThemeRepository {
} }
private async deactivateAllOthers(excludeId: string): Promise<void> { private async deactivateAllOthers(excludeId: string): Promise<void> {
const query = ` const resources = await collection().rawQuery<Theme>(
SELECT * FROM c 'SELECT * FROM c WHERE c.type = @type AND c.id != @excludeId AND c.is_active = true',
WHERE c.type = 'theme' AND c.id != @excludeId AND c.is_active = true { type: 'theme', excludeId }
`; );
const { resources } = await container()
.items.query({ query, parameters: [{ name: '@excludeId', value: excludeId }] })
.fetchAll();
for (const item of resources) { for (const item of resources) {
await container() await collection().update(item.id, item.id, {
.item(item.id, item.id) is_active: false,
.replace({ updated_at: new Date().toISOString(),
...item, });
is_active: false,
updated_at: new Date().toISOString(),
});
} }
} }
} }

View File

@ -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 { getCollection } from '@/lib/datastore';
import { getContainer } from '@/lib/cosmos';
import { PRODUCT_ID } from '@/lib/product-config'; import { PRODUCT_ID } from '@/lib/product-config';
import type { BaseDocument, DocumentCollection } from '@bytelyst/datastore';
export interface ApiTokenDoc { export interface ApiTokenDoc extends BaseDocument {
id: string;
productId: string; // multi-tenancy key from @bytelyst/config
userId: string; userId: string;
userName: string; userName: string;
name: string; name: string;
@ -24,8 +22,8 @@ export interface ApiTokenDoc {
export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>; export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>;
function container() { function collection(): DocumentCollection<ApiTokenDoc> {
return getContainer('api_tokens'); return getCollection<ApiTokenDoc>('api_tokens', '/userId');
} }
function stripHash(doc: ApiTokenDoc): ApiTokenResponse { 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> { export async function getTokenById(id: string, userId: string): Promise<ApiTokenDoc | null> {
try { return collection().findById(id, userId);
const { resource } = await container().item(id, userId).read<ApiTokenDoc>();
return resource ?? null;
} catch {
return null;
}
} }
export async function listTokens(limit = 100, productId = PRODUCT_ID): Promise<ApiTokenResponse[]> { export async function listTokens(limit = 100, productId = PRODUCT_ID): Promise<ApiTokenResponse[]> {
const query: SqlQuerySpec = { const resources = await collection().findMany({
query: filter: { productId, status: { $ne: 'expired' } },
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit", sort: { createdAt: -1 },
parameters: [ limit,
{ name: '@productId', value: productId }, });
{ name: '@limit', value: limit },
],
};
const { resources } = await container().items.query<ApiTokenDoc>(query).fetchAll();
return resources.map(stripHash); return resources.map(stripHash);
} }
@ -60,39 +49,29 @@ export async function listTokensByUser(
userId: string, userId: string,
productId = PRODUCT_ID productId = PRODUCT_ID
): Promise<ApiTokenResponse[]> { ): Promise<ApiTokenResponse[]> {
const query: SqlQuerySpec = { const resources = await collection().findMany({
query: filter: { productId, userId },
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', sort: { createdAt: -1 },
parameters: [ });
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
};
const { resources } = await container().items.query<ApiTokenDoc>(query).fetchAll();
return resources.map(stripHash); return resources.map(stripHash);
} }
export async function createToken(doc: ApiTokenDoc): Promise<ApiTokenResponse> { export async function createToken(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
const { resource } = await container().items.create<ApiTokenDoc>(doc); const resource = await collection().create(doc);
return stripHash(resource!); return stripHash(resource);
} }
export async function revokeToken(id: string, userId: string): Promise<boolean> { export async function revokeToken(id: string, userId: string): Promise<boolean> {
const existing = await getTokenById(id, userId); const existing = await getTokenById(id, userId);
if (!existing) return false; if (!existing) return false;
await container() await collection().update(id, userId, { status: 'revoked' } as Partial<ApiTokenDoc>);
.item(id, userId)
.replace<ApiTokenDoc>({
...existing,
status: 'revoked',
});
return true; return true;
} }
export async function deleteToken(id: string, userId: string): Promise<boolean> { export async function deleteToken(id: string, userId: string): Promise<boolean> {
try { try {
await container().item(id, userId).delete(); await collection().delete(id, userId);
return true; return true;
} catch { } catch {
return false; 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> { 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'`; return collection().count({ productId, status: 'active' });
const { resources } = await container().items.query<number>(query).fetchAll();
return resources[0] ?? 0;
} }

View File

@ -1,14 +1,12 @@
/** /**
* User repository direct Cosmos DB CRUD operations. * User repository cloud-agnostic datastore operations.
*/ */
import { SqlQuerySpec } from '@azure/cosmos'; import { getCollection } from '@/lib/datastore';
import { getContainer } from '@/lib/cosmos';
import { PRODUCT_ID } from '@/lib/product-config'; import { PRODUCT_ID } from '@/lib/product-config';
import type { BaseDocument, DocumentCollection } from '@bytelyst/datastore';
export interface UserDoc { export interface UserDoc extends BaseDocument {
id: string;
productId: string; // multi-tenancy key from @bytelyst/config
email: string; email: string;
name: string; name: string;
passwordHash: string; passwordHash: string;
@ -24,8 +22,8 @@ export interface UserDoc {
export type UserResponse = Omit<UserDoc, 'passwordHash'>; export type UserResponse = Omit<UserDoc, 'passwordHash'>;
function container() { function collection(): DocumentCollection<UserDoc> {
return getContainer('users'); return getCollection<UserDoc>('users', '/id');
} }
function stripPassword(doc: UserDoc): UserResponse { function stripPassword(doc: UserDoc): UserResponse {
@ -35,27 +33,16 @@ function stripPassword(doc: UserDoc): UserResponse {
} }
export async function getUserById(id: string): Promise<UserDoc | null> { export async function getUserById(id: string): Promise<UserDoc | null> {
try { return collection().findById(id, id);
const { resource } = await container().item(id, id).read<UserDoc>();
return resource ?? null;
} catch {
return null;
}
} }
export async function getUserByEmail( export async function getUserByEmail(
email: string, email: string,
productId = PRODUCT_ID productId = PRODUCT_ID
): Promise<UserDoc | null> { ): Promise<UserDoc | null> {
const query: SqlQuerySpec = { return collection().findOne({
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email', filter: { productId, email: email.toLowerCase() },
parameters: [ });
{ name: '@productId', value: productId },
{ name: '@email', value: email.toLowerCase() },
],
};
const { resources } = await container().items.query<UserDoc>(query).fetchAll();
return resources[0] ?? null;
} }
export async function listUsers( export async function listUsers(
@ -63,25 +50,21 @@ export async function listUsers(
offset = 0, offset = 0,
productId = PRODUCT_ID productId = PRODUCT_ID
): Promise<UserResponse[]> { ): Promise<UserResponse[]> {
const query: SqlQuerySpec = { const resources = await collection().findMany({
query: filter: { productId },
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', sort: { createdAt: -1 },
parameters: [ offset,
{ name: '@productId', value: productId }, limit,
{ name: '@offset', value: offset }, });
{ name: '@limit', value: limit },
],
};
const { resources } = await container().items.query<UserDoc>(query).fetchAll();
return resources.map(stripPassword); return resources.map(stripPassword);
} }
export async function createUser(doc: UserDoc): Promise<UserResponse> { export async function createUser(doc: UserDoc): Promise<UserResponse> {
const { resource } = await container().items.create<UserDoc>({ const resource = await collection().create({
...doc, ...doc,
email: doc.email.toLowerCase(), email: doc.email.toLowerCase(),
}); });
return stripPassword(resource!); return stripPassword(resource);
} }
export async function updateUser( export async function updateUser(
@ -91,14 +74,16 @@ export async function updateUser(
const existing = await getUserById(id); const existing = await getUserById(id);
if (!existing) return null; if (!existing) return null;
const updated = { ...existing, ...updates, lastActive: new Date().toISOString() }; const updated = await collection().update(id, id, {
const { resource } = await container().item(id, id).replace<UserDoc>(updated); ...updates,
return stripPassword(resource!); lastActive: new Date().toISOString(),
});
return stripPassword(updated);
} }
export async function deleteUser(id: string): Promise<boolean> { export async function deleteUser(id: string): Promise<boolean> {
try { try {
await container().item(id, id).delete(); await collection().delete(id, id);
return true; return true;
} catch { } catch {
return false; return false;
@ -106,20 +91,15 @@ export async function deleteUser(id: string): Promise<boolean> {
} }
export async function countUsers(productId = PRODUCT_ID): Promise<number> { export async function countUsers(productId = PRODUCT_ID): Promise<number> {
const { resources } = await container() return collection().count({ productId });
.items.query<number>({
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources[0] ?? 0;
} }
export async function countUsersByPlan(productId = PRODUCT_ID): Promise<Record<string, number>> { 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 collection().aggregate<{ plan: string; count: number }>({
const { resources } = await container() groupBy: 'plan',
.items.query<{ plan: string; count: number }>(query) aggregations: [{ field: 'id', op: 'count', alias: 'count' }],
.fetchAll(); filter: { productId },
});
const result: Record<string, number> = { free: 0, pro: 0, enterprise: 0 }; const result: Record<string, number> = { free: 0, pro: 0, enterprise: 0 };
for (const r of resources) { for (const r of resources) {
result[r.plan] = r.count; result[r.plan] = r.count;

3
pnpm-lock.yaml generated
View File

@ -89,6 +89,9 @@ importers:
'@bytelyst/cosmos': '@bytelyst/cosmos':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/cosmos version: link:../../packages/cosmos
'@bytelyst/datastore':
specifier: workspace:*
version: link:../../packages/datastore
'@bytelyst/errors': '@bytelyst/errors':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/errors version: link:../../packages/errors