diff --git a/dashboards/admin-web/package.json b/dashboards/admin-web/package.json index 639dbb78..e4559958 100644 --- a/dashboards/admin-web/package.json +++ b/dashboards/admin-web/package.json @@ -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:*", diff --git a/dashboards/admin-web/src/app/api/analytics/retention/route.ts b/dashboards/admin-web/src/app/api/analytics/retention/route.ts index 1bdb6546..16ee2d2a 100644 --- a/dashboards/admin-web/src/app/api/analytics/retention/route.ts +++ b/dashboards/admin-web/src/app/api/analytics/retention/route.ts @@ -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>(); for (const r of usageRecords) { diff --git a/dashboards/admin-web/src/app/api/analytics/revenue/route.ts b/dashboards/admin-web/src/app/api/analytics/revenue/route.ts index 57f0d7fe..e4ac4a46 100644 --- a/dashboards/admin-web/src/app/api/analytics/revenue/route.ts +++ b/dashboards/admin-web/src/app/api/analytics/revenue/route.ts @@ -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); diff --git a/dashboards/admin-web/src/app/api/health/route.ts b/dashboards/admin-web/src/app/api/health/route.ts index 6cb401b9..d589b6ae 100644 --- a/dashboards/admin-web/src/app/api/health/route.ts +++ b/dashboards/admin-web/src/app/api/health/route.ts @@ -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 { +async function checkDatastore(): Promise { 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'; diff --git a/dashboards/admin-web/src/app/api/settings/platform/route.ts b/dashboards/admin-web/src/app/api/settings/platform/route.ts index 5de0289f..05a2cab2 100644 --- a/dashboards/admin-web/src/app/api/settings/platform/route.ts +++ b/dashboards/admin-web/src/app/api/settings/platform/route.ts @@ -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 { + 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(); - 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(); - 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); diff --git a/dashboards/admin-web/src/lib/cosmos.ts b/dashboards/admin-web/src/lib/cosmos.ts index a1d190dc..ae1e4053 100644 --- a/dashboards/admin-web/src/lib/cosmos.ts +++ b/dashboards/admin-web/src/lib/cosmos.ts @@ -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 = { // 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 }; diff --git a/dashboards/admin-web/src/lib/datastore.ts b/dashboards/admin-web/src/lib/datastore.ts new file mode 100644 index 00000000..6e712b26 --- /dev/null +++ b/dashboards/admin-web/src/lib/datastore.ts @@ -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( + name: string, + partitionKeyPath: string = '/id' +): DocumentCollection { + if (!_provider) { + initDatastore(); + } + return _provider!.getCollection(name, partitionKeyPath); +} + +/** + * Check datastore health. + */ +export async function isDatastoreHealthy(): Promise { + if (!_provider) initDatastore(); + return _provider!.isHealthy(); +} diff --git a/dashboards/admin-web/src/lib/repositories/theme.ts b/dashboards/admin-web/src/lib/repositories/theme.ts index ff1c24d4..7e3081b6 100644 --- a/dashboards/admin-web/src/lib/repositories/theme.ts +++ b/dashboards/admin-web/src/lib/repositories/theme.ts @@ -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 { + 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 { - try { - const { resource } = await container().item(id, id).read(); - return resource; - } catch { - return null; - } + return collection().findById(id, id); } async getActive(): Promise { - 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( + `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 { - 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( + "SELECT * FROM c WHERE c.type = 'theme' ORDER BY c.created_at DESC" + ); } async update(id: string, update: ThemeUpdate): Promise { @@ -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 { 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 { - 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( + '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(), + }); } } } diff --git a/dashboards/admin-web/src/lib/repositories/tokens.ts b/dashboards/admin-web/src/lib/repositories/tokens.ts index 1fa16e00..b22117fe 100644 --- a/dashboards/admin-web/src/lib/repositories/tokens.ts +++ b/dashboards/admin-web/src/lib/repositories/tokens.ts @@ -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; -function container() { - return getContainer('api_tokens'); +function collection(): DocumentCollection { + return getCollection('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 { - try { - const { resource } = await container().item(id, userId).read(); - return resource ?? null; - } catch { - return null; - } + return collection().findById(id, userId); } export async function listTokens(limit = 100, productId = PRODUCT_ID): Promise { - 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(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 { - 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(query).fetchAll(); + const resources = await collection().findMany({ + filter: { productId, userId }, + sort: { createdAt: -1 }, + }); return resources.map(stripHash); } export async function createToken(doc: ApiTokenDoc): Promise { - const { resource } = await container().items.create(doc); - return stripHash(resource!); + const resource = await collection().create(doc); + return stripHash(resource); } export async function revokeToken(id: string, userId: string): Promise { const existing = await getTokenById(id, userId); if (!existing) return false; - await container() - .item(id, userId) - .replace({ - ...existing, - status: 'revoked', - }); + await collection().update(id, userId, { status: 'revoked' } as Partial); return true; } export async function deleteToken(id: string, userId: string): Promise { 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 } export async function countActiveTokens(productId = PRODUCT_ID): Promise { - const query = `SELECT VALUE COUNT(1) FROM c WHERE c.productId = '${productId}' AND c.status = 'active'`; - const { resources } = await container().items.query(query).fetchAll(); - return resources[0] ?? 0; + return collection().count({ productId, status: 'active' }); } diff --git a/dashboards/admin-web/src/lib/repositories/users.ts b/dashboards/admin-web/src/lib/repositories/users.ts index 6970a883..021c2231 100644 --- a/dashboards/admin-web/src/lib/repositories/users.ts +++ b/dashboards/admin-web/src/lib/repositories/users.ts @@ -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; -function container() { - return getContainer('users'); +function collection(): DocumentCollection { + return getCollection('users', '/id'); } function stripPassword(doc: UserDoc): UserResponse { @@ -35,27 +33,16 @@ function stripPassword(doc: UserDoc): UserResponse { } export async function getUserById(id: string): Promise { - try { - const { resource } = await container().item(id, id).read(); - return resource ?? null; - } catch { - return null; - } + return collection().findById(id, id); } export async function getUserByEmail( email: string, productId = PRODUCT_ID ): Promise { - 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(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 { - 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(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 { - const { resource } = await container().items.create({ + 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(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 { 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 { } export async function countUsers(productId = PRODUCT_ID): Promise { - const { resources } = await container() - .items.query({ - 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> { - 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 = { free: 0, pro: 0, enterprise: 0 }; for (const r of resources) { result[r.plan] = r.count; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80ea7a16..fd40a23c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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