refactor(platform-service): migrate remaining 14 repositories to @bytelyst/datastore
Migrated modules: audit, auth, invitations, items, jobs, licenses, maintenance, notifications, subscriptions, telemetry, tokens, usage, waitlist, webhooks. Updated 4 test files (notifications, subscriptions, tokens, usage) from Cosmos SDK mocks to MemoryDatastoreProvider. Zero cosmos.js imports remain in modules/. All 66 test files pass (746 tests).
This commit is contained in:
parent
e355cb0c1b
commit
b69abf44c7
@ -1,74 +1,58 @@
|
||||
/**
|
||||
* Audit repository — Cosmos DB CRUD.
|
||||
* Audit repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { AuditDoc, QueryAuditInput } from './types.js';
|
||||
|
||||
// Default TTL: 90 days in seconds
|
||||
const DEFAULT_TTL = 90 * 24 * 60 * 60;
|
||||
|
||||
function container() {
|
||||
return getContainer('audit_log');
|
||||
function collection() {
|
||||
return getCollection<AuditDoc>('audit_log', '/productId');
|
||||
}
|
||||
|
||||
export async function create(doc: AuditDoc): Promise<AuditDoc> {
|
||||
const { resource } = await container().items.create({
|
||||
return collection().create({
|
||||
...doc,
|
||||
ttl: doc.ttl ?? DEFAULT_TTL,
|
||||
});
|
||||
return resource as AuditDoc;
|
||||
}
|
||||
|
||||
export async function query(input: QueryAuditInput, productId: string): Promise<AuditDoc[]> {
|
||||
const { userId, action, category, days, limit, offset } = input;
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString();
|
||||
|
||||
let queryText = 'SELECT * FROM c WHERE c.productId = @productId AND c.createdAt >= @since';
|
||||
const parameters: { name: string; value: string | number }[] = [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@since', value: since },
|
||||
];
|
||||
const filter: FilterMap = {
|
||||
productId,
|
||||
createdAt: { $gte: since },
|
||||
};
|
||||
if (userId) filter.userId = userId;
|
||||
if (action) filter.action = action;
|
||||
if (category) filter.category = category;
|
||||
|
||||
if (userId) {
|
||||
queryText += ' AND c.userId = @userId';
|
||||
parameters.push({ name: '@userId', value: userId });
|
||||
}
|
||||
if (action) {
|
||||
queryText += ' AND c.action = @action';
|
||||
parameters.push({ name: '@action', value: action });
|
||||
}
|
||||
if (category) {
|
||||
queryText += ' AND c.category = @category';
|
||||
parameters.push({ name: '@category', value: category });
|
||||
}
|
||||
|
||||
queryText += ' ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit';
|
||||
parameters.push({ name: '@offset', value: offset });
|
||||
parameters.push({ name: '@limit', value: limit });
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<AuditDoc>({ query: queryText, parameters })
|
||||
.fetchAll();
|
||||
return resources;
|
||||
const results = await collection().findMany({
|
||||
filter,
|
||||
sort: { createdAt: -1 },
|
||||
limit: limit + offset,
|
||||
});
|
||||
return results.slice(offset);
|
||||
}
|
||||
|
||||
export async function getStats(days = 30, productId?: string): Promise<Record<string, number>> {
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString();
|
||||
const { resources } = await container()
|
||||
.items.query<{ action: string; count: number }>({
|
||||
query:
|
||||
'SELECT c.action, COUNT(1) as count FROM c WHERE c.productId = @productId AND c.createdAt >= @since GROUP BY c.action',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId ?? '' },
|
||||
{ name: '@since', value: since },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
// Fetch docs and aggregate in-memory
|
||||
const docs = await collection().findMany({
|
||||
filter: {
|
||||
productId: productId ?? '',
|
||||
createdAt: { $gte: since },
|
||||
},
|
||||
});
|
||||
|
||||
const stats: Record<string, number> = {};
|
||||
for (const r of resources) {
|
||||
stats[r.action] = r.count;
|
||||
for (const doc of docs) {
|
||||
stats[doc.action] = (stats[doc.action] ?? 0) + 1;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
@ -1,41 +1,32 @@
|
||||
/**
|
||||
* Auth user repository — Cosmos DB.
|
||||
* Auth user repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { UserDoc, PasswordResetTokenDoc, EmailVerificationDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('users');
|
||||
function usersCollection() {
|
||||
return getCollection<UserDoc>('users', '/id');
|
||||
}
|
||||
|
||||
export async function getByEmail(email: string, productId: string): Promise<UserDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<UserDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@email', value: email.toLowerCase() },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return usersCollection().findOne({
|
||||
filter: { productId, email: email.toLowerCase() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getById(id: string): Promise<UserDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
return resource ?? null;
|
||||
return await usersCollection().findById(id, id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(user: UserDoc): Promise<UserDoc> {
|
||||
const { resource } = await container().items.create(user);
|
||||
return resource as UserDoc;
|
||||
return usersCollection().create(user);
|
||||
}
|
||||
|
||||
export async function updatePlan(
|
||||
@ -44,17 +35,12 @@ export async function updatePlan(
|
||||
plan: UserDoc['plan']
|
||||
): Promise<UserDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (!resource || resource.productId !== productId) return null;
|
||||
const { resource: updated } = await container()
|
||||
.item(id, id)
|
||||
.replace<UserDoc>({
|
||||
...resource,
|
||||
plan,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (!updated) return null;
|
||||
return updated;
|
||||
const existing = await usersCollection().findById(id, id);
|
||||
if (!existing || existing.productId !== productId) return null;
|
||||
return await usersCollection().update(id, id, {
|
||||
plan,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Partial<UserDoc>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -62,16 +48,11 @@ export async function updatePlan(
|
||||
|
||||
export async function updateLastLogin(id: string): Promise<void> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (resource) {
|
||||
await container()
|
||||
.item(id, id)
|
||||
.replace({
|
||||
...resource,
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await usersCollection().update(id, id, {
|
||||
lastLoginAt: now,
|
||||
updatedAt: now,
|
||||
} as Partial<UserDoc>);
|
||||
} catch {
|
||||
// Non-critical — don't throw
|
||||
}
|
||||
@ -80,39 +61,27 @@ export async function updateLastLogin(id: string): Promise<void> {
|
||||
// ── Admin user management ────────────────────────────────────
|
||||
|
||||
export async function list(productId: string, limit = 100, offset = 0): Promise<UserDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<UserDoc>({
|
||||
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 },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
const results = await usersCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
limit: limit + offset,
|
||||
});
|
||||
return results.slice(offset);
|
||||
}
|
||||
|
||||
export async function count(productId: string): 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 usersCollection().count({ productId });
|
||||
}
|
||||
|
||||
export async function countByPlan(productId: string): Promise<Record<string, number>> {
|
||||
const { resources } = await container()
|
||||
.items.query<{ plan: string; cnt: number }>({
|
||||
query: 'SELECT c.plan, COUNT(1) AS cnt FROM c WHERE c.productId = @productId GROUP BY c.plan',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
// Fetch all users and aggregate in-memory
|
||||
const docs = await usersCollection().findMany({
|
||||
filter: { productId },
|
||||
});
|
||||
const result: Record<string, number> = {};
|
||||
for (const r of resources) result[r.plan] = r.cnt;
|
||||
for (const doc of docs) {
|
||||
result[doc.plan] = (result[doc.plan] ?? 0) + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -123,15 +92,10 @@ export async function update(
|
||||
>
|
||||
): Promise<UserDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (!resource) return null;
|
||||
const merged = {
|
||||
...resource,
|
||||
return await usersCollection().update(id, id, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const { resource: updated } = await container().item(id, id).replace<UserDoc>(merged);
|
||||
return updated ?? null;
|
||||
} as Partial<UserDoc>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -139,7 +103,7 @@ export async function update(
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await usersCollection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -156,15 +120,10 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
|
||||
export async function updatePassword(id: string, newPasswordHash: string): Promise<boolean> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (!resource) return false;
|
||||
await container()
|
||||
.item(id, id)
|
||||
.replace({
|
||||
...resource,
|
||||
passwordHash: newPasswordHash,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await usersCollection().update(id, id, {
|
||||
passwordHash: newPasswordHash,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Partial<UserDoc>);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -173,15 +132,10 @@ export async function updatePassword(id: string, newPasswordHash: string): Promi
|
||||
|
||||
export async function setEmailVerified(id: string, verified: boolean): Promise<boolean> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (!resource) return false;
|
||||
await container()
|
||||
.item(id, id)
|
||||
.replace({
|
||||
...resource,
|
||||
emailVerified: verified,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await usersCollection().update(id, id, {
|
||||
emailVerified: verified,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Partial<UserDoc>);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -190,8 +144,8 @@ export async function setEmailVerified(id: string, verified: boolean): Promise<b
|
||||
|
||||
// ── Password Reset Tokens ────────────────────────────────────
|
||||
|
||||
function resetTokensContainer() {
|
||||
return getContainer('password_reset_tokens');
|
||||
function resetTokensCollection() {
|
||||
return getCollection<PasswordResetTokenDoc>('password_reset_tokens', '/productId');
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
@ -199,87 +153,55 @@ export function hashToken(token: string): string {
|
||||
}
|
||||
|
||||
export async function createResetToken(doc: PasswordResetTokenDoc): Promise<PasswordResetTokenDoc> {
|
||||
const { resource } = await resetTokensContainer().items.create(doc);
|
||||
return resource as PasswordResetTokenDoc;
|
||||
return resetTokensCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function findResetToken(
|
||||
tokenHash: string,
|
||||
productId: string
|
||||
): Promise<PasswordResetTokenDoc | null> {
|
||||
const { resources } = await resetTokensContainer()
|
||||
.items.query<PasswordResetTokenDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.tokenHash = @tokenHash AND NOT IS_DEFINED(c.usedAt)',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@tokenHash', value: tokenHash },
|
||||
],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return resetTokensCollection().findOne({
|
||||
filter: { productId, tokenHash, usedAt: { $exists: false } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markResetTokenUsed(id: string, productId: string): Promise<void> {
|
||||
const { resource } = await resetTokensContainer()
|
||||
.item(id, productId)
|
||||
.read<PasswordResetTokenDoc>();
|
||||
if (resource) {
|
||||
await resetTokensContainer()
|
||||
.item(id, productId)
|
||||
.replace({
|
||||
...resource,
|
||||
usedAt: new Date().toISOString(),
|
||||
});
|
||||
try {
|
||||
await resetTokensCollection().update(id, productId, {
|
||||
usedAt: new Date().toISOString(),
|
||||
} as Partial<PasswordResetTokenDoc>);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ── Email Verification Tokens ────────────────────────────────
|
||||
|
||||
function emailVerificationsContainer() {
|
||||
return getContainer('email_verifications');
|
||||
function emailVerificationsCollection() {
|
||||
return getCollection<EmailVerificationDoc>('email_verifications', '/productId');
|
||||
}
|
||||
|
||||
export async function createEmailVerification(
|
||||
doc: EmailVerificationDoc
|
||||
): Promise<EmailVerificationDoc> {
|
||||
const { resource } = await emailVerificationsContainer().items.create(doc);
|
||||
return resource as EmailVerificationDoc;
|
||||
return emailVerificationsCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function findEmailVerification(
|
||||
tokenHash: string,
|
||||
productId: string
|
||||
): Promise<EmailVerificationDoc | null> {
|
||||
const { resources } = await emailVerificationsContainer()
|
||||
.items.query<EmailVerificationDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.tokenHash = @tokenHash AND NOT IS_DEFINED(c.verifiedAt)',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@tokenHash', value: tokenHash },
|
||||
],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return emailVerificationsCollection().findOne({
|
||||
filter: { productId, tokenHash, verifiedAt: { $exists: false } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markEmailVerified(id: string, productId: string): Promise<void> {
|
||||
const { resource } = await emailVerificationsContainer()
|
||||
.item(id, productId)
|
||||
.read<EmailVerificationDoc>();
|
||||
if (resource) {
|
||||
await emailVerificationsContainer()
|
||||
.item(id, productId)
|
||||
.replace({
|
||||
...resource,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
});
|
||||
try {
|
||||
await emailVerificationsCollection().update(id, productId, {
|
||||
verifiedAt: new Date().toISOString(),
|
||||
} as Partial<EmailVerificationDoc>);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Invitations repository — Cosmos DB CRUD operations.
|
||||
* Invitations repository — cloud-agnostic via @bytelyst/datastore.
|
||||
* Consolidated from admin-dashboard-web + user-dashboard-web repos.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { InvitationCodeDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'invitation_codes';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<InvitationCodeDoc>('invitation_codes', '/id');
|
||||
}
|
||||
|
||||
export async function list(
|
||||
@ -17,24 +15,17 @@ export async function list(
|
||||
offset = 0,
|
||||
productId?: string
|
||||
): Promise<InvitationCodeDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<InvitationCodeDoc>({
|
||||
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 },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
const results = await collection().findMany({
|
||||
filter: { productId: productId ?? '' },
|
||||
sort: { createdAt: -1 },
|
||||
limit: limit + offset,
|
||||
});
|
||||
return results.slice(offset);
|
||||
}
|
||||
|
||||
export async function getById(id: string): Promise<InvitationCodeDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<InvitationCodeDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -44,21 +35,13 @@ export async function getByCode(
|
||||
code: string,
|
||||
productId?: string
|
||||
): Promise<InvitationCodeDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<InvitationCodeDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.code = @code',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId ?? '' },
|
||||
{ name: '@code', value: code.toUpperCase() },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { productId: productId ?? '', code: code.toUpperCase() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(doc: InvitationCodeDoc): Promise<InvitationCodeDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as InvitationCodeDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -66,11 +49,7 @@ export async function update(
|
||||
updates: Partial<InvitationCodeDoc>
|
||||
): Promise<InvitationCodeDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<InvitationCodeDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, id).replace(merged);
|
||||
return resource as InvitationCodeDoc;
|
||||
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -93,7 +72,7 @@ export async function redeem(code: string, userId: string): Promise<InvitationCo
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -101,11 +80,5 @@ export async function remove(id: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function count(productId?: string): 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: productId ?? '' });
|
||||
}
|
||||
|
||||
@ -1,97 +1,66 @@
|
||||
/**
|
||||
* Tracker items repository — Cosmos DB CRUD.
|
||||
* Tracker items repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { TrackerItemDoc, ListItemsQuery } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('tracker_items');
|
||||
function collection() {
|
||||
return getCollection<TrackerItemDoc>('tracker_items', '/id');
|
||||
}
|
||||
|
||||
export async function list(
|
||||
query: ListItemsQuery
|
||||
): Promise<{ items: TrackerItemDoc[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: { name: string; value: string | number }[] = [];
|
||||
const filter: FilterMap = {};
|
||||
|
||||
if (query.productId) {
|
||||
conditions.push('c.productId = @productId');
|
||||
params.push({ name: '@productId', value: query.productId });
|
||||
}
|
||||
if (query.type) {
|
||||
conditions.push('c.type = @type');
|
||||
params.push({ name: '@type', value: query.type });
|
||||
}
|
||||
if (query.status) {
|
||||
conditions.push('c.status = @status');
|
||||
params.push({ name: '@status', value: query.status });
|
||||
}
|
||||
if (query.priority) {
|
||||
conditions.push('c.priority = @priority');
|
||||
params.push({ name: '@priority', value: query.priority });
|
||||
}
|
||||
if (query.assignee) {
|
||||
conditions.push('c.assignee = @assignee');
|
||||
params.push({ name: '@assignee', value: query.assignee });
|
||||
}
|
||||
if (query.productId) filter.productId = query.productId;
|
||||
if (query.type) filter.type = query.type;
|
||||
if (query.status) filter.status = query.status;
|
||||
if (query.priority) filter.priority = query.priority;
|
||||
if (query.assignee) filter.assignee = query.assignee;
|
||||
if (query.visibility) filter.visibility = query.visibility;
|
||||
|
||||
// Fetch all matching docs — label and q filtering done in-memory
|
||||
const sortField = query.sortBy === 'priority' ? 'priorityOrder' : query.sortBy;
|
||||
const sortDir = query.sortOrder === 'desc' ? -1 : 1;
|
||||
|
||||
let allDocs = await collection().findMany({
|
||||
filter,
|
||||
sort: { [sortField]: sortDir },
|
||||
});
|
||||
|
||||
// In-memory label filter (ARRAY_CONTAINS)
|
||||
if (query.label) {
|
||||
conditions.push('ARRAY_CONTAINS(c.labels, @label)');
|
||||
params.push({ name: '@label', value: query.label });
|
||||
}
|
||||
if (query.visibility) {
|
||||
conditions.push('c.visibility = @visibility');
|
||||
params.push({ name: '@visibility', value: query.visibility });
|
||||
const label = query.label;
|
||||
allDocs = allDocs.filter(d => d.labels?.includes(label));
|
||||
}
|
||||
|
||||
// In-memory text search
|
||||
if (query.q) {
|
||||
conditions.push(
|
||||
'(CONTAINS(LOWER(c.title), LOWER(@q)) OR CONTAINS(LOWER(c.description), LOWER(@q)))'
|
||||
const q = query.q.toLowerCase();
|
||||
allDocs = allDocs.filter(
|
||||
d => d.title?.toLowerCase().includes(q) || d.description?.toLowerCase().includes(q)
|
||||
);
|
||||
params.push({ name: '@q', value: query.q });
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const total = allDocs.length;
|
||||
const items = allDocs.slice(query.offset, query.offset + query.limit);
|
||||
|
||||
// Priority sort needs special handling — map to numeric
|
||||
const sortField = query.sortBy === 'priority' ? 'c.priorityOrder' : `c.${query.sortBy}`;
|
||||
const orderDir = query.sortOrder.toUpperCase();
|
||||
|
||||
// Count query
|
||||
const countResult = await container()
|
||||
.items.query<number>({
|
||||
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
|
||||
parameters: params,
|
||||
})
|
||||
.fetchAll();
|
||||
const total = countResult.resources[0] ?? 0;
|
||||
|
||||
// Data query with pagination
|
||||
const { resources } = await container()
|
||||
.items.query<TrackerItemDoc>({
|
||||
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
|
||||
parameters: [
|
||||
...params,
|
||||
{ name: '@offset', value: query.offset },
|
||||
{ name: '@limit', value: query.limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
return { items: resources, total };
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
export async function getById(id: string): Promise<TrackerItemDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<TrackerItemDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: TrackerItemDoc): Promise<TrackerItemDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as TrackerItemDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -99,11 +68,7 @@ export async function update(
|
||||
updates: Partial<TrackerItemDoc>
|
||||
): Promise<TrackerItemDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<TrackerItemDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, id).replace(merged);
|
||||
return resource as TrackerItemDoc;
|
||||
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -111,7 +76,7 @@ export async function update(
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -1,42 +1,32 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { NotFoundError } from '../../lib/errors.js';
|
||||
import type { JobDefinitionDoc, JobRunDoc } from './types.js';
|
||||
|
||||
const DEFS_CONTAINER = 'job_definitions';
|
||||
const RUNS_CONTAINER = 'job_runs';
|
||||
|
||||
function defsContainer() {
|
||||
return getContainer(DEFS_CONTAINER);
|
||||
function defsCollection() {
|
||||
return getCollection<JobDefinitionDoc>('job_definitions', '/productId');
|
||||
}
|
||||
|
||||
function runsContainer() {
|
||||
return getContainer(RUNS_CONTAINER);
|
||||
function runsCollection() {
|
||||
return getCollection<JobRunDoc>('job_runs', '/pk');
|
||||
}
|
||||
|
||||
// ── Job Definition CRUD ──────────────────────────────────────
|
||||
|
||||
export async function listJobDefinitions(productId: string): Promise<JobDefinitionDoc[]> {
|
||||
const { resources } = await defsContainer()
|
||||
.items.query<JobDefinitionDoc>(
|
||||
{
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.name',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return defsCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { name: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJobDefinition(id: string, productId: string): Promise<JobDefinitionDoc> {
|
||||
const { resource } = await defsContainer().item(id, productId).read<JobDefinitionDoc>();
|
||||
if (!resource) throw new NotFoundError(`Job definition '${id}' not found`);
|
||||
return resource;
|
||||
const doc = await defsCollection().findById(id, productId);
|
||||
if (!doc) throw new NotFoundError(`Job definition '${id}' not found`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function upsertJobDefinition(doc: JobDefinitionDoc): Promise<JobDefinitionDoc> {
|
||||
const { resource } = await defsContainer().items.upsert(doc);
|
||||
return resource as unknown as JobDefinitionDoc;
|
||||
return defsCollection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function updateJobDefinition(
|
||||
@ -44,27 +34,20 @@ export async function updateJobDefinition(
|
||||
productId: string,
|
||||
updates: Partial<JobDefinitionDoc>
|
||||
): Promise<JobDefinitionDoc> {
|
||||
const existing = await getJobDefinition(id, productId);
|
||||
const updated: JobDefinitionDoc = {
|
||||
...existing,
|
||||
return defsCollection().update(id, productId, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const { resource } = await defsContainer().item(id, productId).replace(updated);
|
||||
return resource as JobDefinitionDoc;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Job Run CRUD ─────────────────────────────────────────────
|
||||
|
||||
export async function createJobRun(doc: JobRunDoc): Promise<JobRunDoc> {
|
||||
const { resource } = await runsContainer().items.create(doc);
|
||||
return resource as JobRunDoc;
|
||||
return runsCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function updateJobRun(doc: JobRunDoc): Promise<JobRunDoc> {
|
||||
const pk = `${doc.productId}:${doc.jobName}`;
|
||||
const { resource } = await runsContainer().item(doc.id, pk).replace(doc);
|
||||
return resource as JobRunDoc;
|
||||
return runsCollection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function listJobRuns(
|
||||
@ -72,20 +55,9 @@ export async function listJobRuns(
|
||||
jobName: string,
|
||||
limit = 20
|
||||
): Promise<JobRunDoc[]> {
|
||||
const pk = `${productId}:${jobName}`;
|
||||
const { resources } = await runsContainer()
|
||||
.items.query<JobRunDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT TOP @limit * FROM c WHERE c.productId = @productId AND c.jobName = @jobName ORDER BY c.startedAt DESC',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@jobName', value: jobName },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
},
|
||||
{ partitionKey: pk }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return runsCollection().findMany({
|
||||
filter: { productId, jobName },
|
||||
sort: { startedAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Licenses repository — Cosmos DB CRUD.
|
||||
* Licenses repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { LicenseDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('licenses');
|
||||
function collection() {
|
||||
return getCollection<LicenseDoc>('licenses', '/userId');
|
||||
}
|
||||
|
||||
export function generateKey(licensePrefix: string): string {
|
||||
@ -16,35 +16,20 @@ export function generateKey(licensePrefix: string): string {
|
||||
}
|
||||
|
||||
export async function getByKey(key: string, productId: string): Promise<LicenseDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<LicenseDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.key = @key',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@key', value: key.toUpperCase() },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { productId, key: key.toUpperCase() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByUserId(userId: string, productId: string): Promise<LicenseDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<LicenseDoc>({
|
||||
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 },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { productId, userId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(doc: LicenseDoc): Promise<LicenseDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as LicenseDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -53,11 +38,10 @@ export async function update(
|
||||
updates: Partial<LicenseDoc>
|
||||
): Promise<LicenseDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, userId).read<LicenseDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, userId).replace(merged);
|
||||
return resource as LicenseDoc;
|
||||
return await collection().update(id, userId, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { MaintenanceConfig, MaintenanceWindow } from './types.js';
|
||||
|
||||
// ── Maintenance Config ───────────────────────────────────────
|
||||
// Stored as a single document per product in the settings container.
|
||||
// Uses the existing settings container (no new container needed).
|
||||
|
||||
const SETTINGS_CONTAINER = 'settings';
|
||||
|
||||
interface MaintenanceSettingsDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
@ -16,8 +14,8 @@ interface MaintenanceSettingsDoc {
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
function settingsContainer() {
|
||||
return getContainer(SETTINGS_CONTAINER);
|
||||
function settingsCollection() {
|
||||
return getCollection<MaintenanceSettingsDoc>('settings', '/productId');
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: MaintenanceConfig = {
|
||||
@ -36,10 +34,8 @@ function docId(productId: string): string {
|
||||
|
||||
export async function getMaintenanceConfig(productId: string): Promise<MaintenanceConfig> {
|
||||
try {
|
||||
const { resource } = await settingsContainer()
|
||||
.item(docId(productId), productId)
|
||||
.read<MaintenanceSettingsDoc>();
|
||||
return resource?.config ?? DEFAULT_CONFIG;
|
||||
const doc = await settingsCollection().findById(docId(productId), productId);
|
||||
return doc?.config ?? DEFAULT_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
@ -57,54 +53,33 @@ export async function updateMaintenanceConfig(
|
||||
config,
|
||||
};
|
||||
|
||||
try {
|
||||
const { resource: existing } = await settingsContainer().item(id, productId).read();
|
||||
if (existing) {
|
||||
const { resource } = await settingsContainer().item(id, productId).replace(doc);
|
||||
return (resource as MaintenanceSettingsDoc).config;
|
||||
}
|
||||
} catch {
|
||||
// Document doesn't exist, create it
|
||||
}
|
||||
|
||||
const { resource } = await settingsContainer().items.create(doc);
|
||||
return (resource as MaintenanceSettingsDoc).config;
|
||||
const result = await settingsCollection().upsert(doc);
|
||||
return result.config;
|
||||
}
|
||||
|
||||
// ── Maintenance Windows ──────────────────────────────────────
|
||||
|
||||
const WINDOWS_CONTAINER = 'maintenance_windows';
|
||||
|
||||
function windowsContainer() {
|
||||
return getContainer(WINDOWS_CONTAINER);
|
||||
function windowsCollection() {
|
||||
return getCollection<MaintenanceWindow>('maintenance_windows', '/productId');
|
||||
}
|
||||
|
||||
export async function listUpcomingWindows(productId: string): Promise<MaintenanceWindow[]> {
|
||||
const now = new Date().toISOString();
|
||||
const { resources } = await windowsContainer()
|
||||
.items.query<MaintenanceWindow>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.scheduledEnd > @now ORDER BY c.scheduledStart ASC',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@now', value: now },
|
||||
],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
// Fetch all for this product and filter in-memory for scheduledEnd > now
|
||||
const all = await windowsCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { scheduledStart: 1 },
|
||||
});
|
||||
return all.filter(w => w.scheduledEnd > now);
|
||||
}
|
||||
|
||||
export async function createWindow(doc: MaintenanceWindow): Promise<MaintenanceWindow> {
|
||||
const { resource } = await windowsContainer().items.create(doc);
|
||||
return resource as MaintenanceWindow;
|
||||
return windowsCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function deleteWindow(id: string, productId: string): Promise<boolean> {
|
||||
try {
|
||||
await windowsContainer().item(id, productId).delete();
|
||||
await windowsCollection().delete(id, productId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -1,27 +1,17 @@
|
||||
/**
|
||||
* Repository tests for notifications module — mocked Cosmos DB.
|
||||
* Repository tests for notifications module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockFetchAll = vi.fn();
|
||||
const mockUpsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const mockRead = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: {
|
||||
query: () => ({ fetchAll: mockFetchAll }),
|
||||
upsert: mockUpsert,
|
||||
create: mockCreate,
|
||||
},
|
||||
item: () => ({ delete: mockDelete, read: mockRead }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { getDevicesByUser, upsertDevice, removeDevice, getPrefs, upsertPrefs } from './repository.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider } from '../../lib/datastore.js';
|
||||
import {
|
||||
getDevicesByUser,
|
||||
upsertDevice,
|
||||
removeDevice,
|
||||
getPrefs,
|
||||
upsertPrefs,
|
||||
} from './repository.js';
|
||||
import type { DeviceDoc, NotificationPrefsDoc } from './types.js';
|
||||
|
||||
const baseDevice: DeviceDoc = {
|
||||
@ -49,18 +39,18 @@ const basePrefs: NotificationPrefsDoc = {
|
||||
|
||||
describe('notifications repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
describe('getDevicesByUser', () => {
|
||||
it('returns devices for user', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseDevice] });
|
||||
await upsertDevice(baseDevice);
|
||||
const result = await getDevicesByUser('user_1', 'lysnrai');
|
||||
expect(result).toEqual([baseDevice]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('dev_1');
|
||||
});
|
||||
|
||||
it('returns empty array when no devices', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getDevicesByUser('user_1', 'lysnrai');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@ -68,41 +58,29 @@ describe('notifications repository', () => {
|
||||
|
||||
describe('upsertDevice', () => {
|
||||
it('upserts and returns device', async () => {
|
||||
mockUpsert.mockResolvedValue({ resource: baseDevice });
|
||||
const result = await upsertDevice(baseDevice);
|
||||
expect(result).toEqual(baseDevice);
|
||||
expect(result.id).toBe('dev_1');
|
||||
expect(result.platform).toBe('ios');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDevice', () => {
|
||||
it('returns true on successful delete', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await upsertDevice(baseDevice);
|
||||
const result = await removeDevice('dev_1', 'user_1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
mockDelete.mockRejectedValue(new Error('Not found'));
|
||||
const result = await removeDevice('dev_1', 'user_1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrefs', () => {
|
||||
it('returns prefs when found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: basePrefs });
|
||||
await upsertPrefs(basePrefs);
|
||||
const result = await getPrefs('user_1', 'lysnrai');
|
||||
expect(result).toEqual(basePrefs);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pushEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await getPrefs('user_1', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when resource is undefined', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await getPrefs('user_1', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -110,9 +88,9 @@ describe('notifications repository', () => {
|
||||
|
||||
describe('upsertPrefs', () => {
|
||||
it('upserts and returns prefs', async () => {
|
||||
mockUpsert.mockResolvedValue({ resource: basePrefs });
|
||||
const result = await upsertPrefs(basePrefs);
|
||||
expect(result).toEqual(basePrefs);
|
||||
expect(result.id).toBe('prefs_lysnrai_user_1');
|
||||
expect(result.pushEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,41 +1,33 @@
|
||||
/**
|
||||
* Notifications repository — Cosmos DB CRUD for devices + prefs.
|
||||
* Notifications repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { DeviceDoc, NotificationPrefsDoc } from './types.js';
|
||||
|
||||
function deviceContainer() {
|
||||
return getContainer('devices');
|
||||
function devicesCollection() {
|
||||
return getCollection<DeviceDoc>('devices', '/userId');
|
||||
}
|
||||
|
||||
function prefsContainer() {
|
||||
return getContainer('notification_prefs');
|
||||
function prefsCollection() {
|
||||
return getCollection<NotificationPrefsDoc>('notification_prefs', '/userId');
|
||||
}
|
||||
|
||||
// ── Devices ──
|
||||
|
||||
export async function getDevicesByUser(userId: string, productId: string): Promise<DeviceDoc[]> {
|
||||
const { resources } = await deviceContainer()
|
||||
.items.query<DeviceDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@userId', value: userId },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return devicesCollection().findMany({
|
||||
filter: { productId, userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertDevice(doc: DeviceDoc): Promise<DeviceDoc> {
|
||||
const { resource } = await deviceContainer().items.upsert<DeviceDoc>(doc);
|
||||
return resource!;
|
||||
return devicesCollection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function removeDevice(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
await deviceContainer().item(id, userId).delete();
|
||||
await devicesCollection().delete(id, userId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -50,14 +42,12 @@ export async function getPrefs(
|
||||
): Promise<NotificationPrefsDoc | null> {
|
||||
const id = `prefs_${productId}_${userId}`;
|
||||
try {
|
||||
const { resource } = await prefsContainer().item(id, userId).read<NotificationPrefsDoc>();
|
||||
return resource ?? null;
|
||||
return await prefsCollection().findById(id, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertPrefs(doc: NotificationPrefsDoc): Promise<NotificationPrefsDoc> {
|
||||
const { resource } = await prefsContainer().items.upsert<NotificationPrefsDoc>(doc);
|
||||
return resource!;
|
||||
return prefsCollection().upsert(doc);
|
||||
}
|
||||
|
||||
@ -1,24 +1,10 @@
|
||||
/**
|
||||
* Repository tests for subscriptions module — mocked Cosmos DB.
|
||||
* Repository tests for subscriptions module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockFetchAll = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockRead = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: {
|
||||
query: () => ({ fetchAll: mockFetchAll }),
|
||||
create: mockCreate,
|
||||
},
|
||||
item: () => ({ read: mockRead, replace: mockReplace }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider } from '../../lib/datastore.js';
|
||||
import {
|
||||
getByUserId,
|
||||
getByStripeCustomerIdAnyProduct,
|
||||
@ -61,18 +47,18 @@ const basePayment: PaymentDoc = {
|
||||
|
||||
describe('subscriptions repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
describe('getByUserId', () => {
|
||||
it('returns subscription when found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
|
||||
await createSubscription(baseSub);
|
||||
const result = await getByUserId('user_1', 'lysnrai');
|
||||
expect(result).toEqual(baseSub);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('sub_1');
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getByUserId('user_1', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -80,13 +66,13 @@ describe('subscriptions repository', () => {
|
||||
|
||||
describe('getByStripeCustomerIdAnyProduct', () => {
|
||||
it('returns subscription when found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
|
||||
await createSubscription(baseSub);
|
||||
const result = await getByStripeCustomerIdAnyProduct('cus_abc');
|
||||
expect(result).toEqual(baseSub);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.stripeCustomerId).toBe('cus_abc');
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getByStripeCustomerIdAnyProduct('cus_none');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -94,13 +80,13 @@ describe('subscriptions repository', () => {
|
||||
|
||||
describe('getByStripeCustomerId', () => {
|
||||
it('returns subscription when found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
|
||||
await createSubscription(baseSub);
|
||||
const result = await getByStripeCustomerId('cus_abc', 'lysnrai');
|
||||
expect(result).toEqual(baseSub);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.stripeCustomerId).toBe('cus_abc');
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getByStripeCustomerId('cus_none', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -108,29 +94,21 @@ describe('subscriptions repository', () => {
|
||||
|
||||
describe('createSubscription', () => {
|
||||
it('creates and returns subscription', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: baseSub });
|
||||
const result = await createSubscription(baseSub);
|
||||
expect(result).toEqual(baseSub);
|
||||
expect(result.id).toBe('sub_1');
|
||||
expect(result.plan).toBe('pro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSubscription', () => {
|
||||
it('merges updates and returns subscription', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseSub });
|
||||
const updated = { ...baseSub, plan: 'enterprise' as const };
|
||||
mockReplace.mockResolvedValue({ resource: updated });
|
||||
await createSubscription(baseSub);
|
||||
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
|
||||
expect(result).toEqual(updated);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.plan).toBe('enterprise');
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on error', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -138,13 +116,13 @@ describe('subscriptions repository', () => {
|
||||
|
||||
describe('getPaymentsByUser', () => {
|
||||
it('returns payments for user', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [basePayment] });
|
||||
await createPayment(basePayment);
|
||||
const result = await getPaymentsByUser('user_1', 'lysnrai');
|
||||
expect(result).toEqual([basePayment]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('pay_1');
|
||||
});
|
||||
|
||||
it('returns empty array when no payments', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getPaymentsByUser('user_1', 'lysnrai');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@ -152,9 +130,9 @@ describe('subscriptions repository', () => {
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('creates and returns payment', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: basePayment });
|
||||
const result = await createPayment(basePayment);
|
||||
expect(result).toEqual(basePayment);
|
||||
expect(result.id).toBe('pay_1');
|
||||
expect(result.amount).toBe(999);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Subscriptions + payments repository — Cosmos DB CRUD.
|
||||
* Subscriptions + payments repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { SubscriptionDoc, PaymentDoc } from './types.js';
|
||||
|
||||
function subContainer() {
|
||||
return getContainer('subscriptions');
|
||||
function subsCollection() {
|
||||
return getCollection<SubscriptionDoc>('subscriptions', '/userId');
|
||||
}
|
||||
|
||||
function payContainer() {
|
||||
return getContainer('payments');
|
||||
function paymentsCollection() {
|
||||
return getCollection<PaymentDoc>('payments', '/userId');
|
||||
}
|
||||
|
||||
// ── Subscriptions ──
|
||||
@ -19,51 +19,33 @@ export async function getByUserId(
|
||||
userId: string,
|
||||
productId: string
|
||||
): Promise<SubscriptionDoc | null> {
|
||||
const { resources } = await subContainer()
|
||||
.items.query<SubscriptionDoc>({
|
||||
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 },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return subsCollection().findOne({
|
||||
filter: { productId, userId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByStripeCustomerIdAnyProduct(
|
||||
stripeCustomerId: string
|
||||
): Promise<SubscriptionDoc | null> {
|
||||
const { resources } = await subContainer()
|
||||
.items.query<SubscriptionDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.stripeCustomerId = @cid ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@cid', value: stripeCustomerId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return subsCollection().findOne({
|
||||
filter: { stripeCustomerId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByStripeCustomerId(
|
||||
stripeCustomerId: string,
|
||||
productId: string
|
||||
): Promise<SubscriptionDoc | null> {
|
||||
const { resources } = await subContainer()
|
||||
.items.query<SubscriptionDoc>({
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.stripeCustomerId = @cid ORDER BY c.createdAt DESC',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@cid', value: stripeCustomerId },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return subsCollection().findOne({
|
||||
filter: { productId, stripeCustomerId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSubscription(doc: SubscriptionDoc): Promise<SubscriptionDoc> {
|
||||
const { resource } = await subContainer().items.create(doc);
|
||||
return resource as SubscriptionDoc;
|
||||
return subsCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
@ -72,11 +54,10 @@ export async function updateSubscription(
|
||||
updates: Partial<SubscriptionDoc>
|
||||
): Promise<SubscriptionDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await subContainer().item(id, userId).read<SubscriptionDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await subContainer().item(id, userId).replace(merged);
|
||||
return resource as SubscriptionDoc;
|
||||
return await subsCollection().update(id, userId, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -89,21 +70,13 @@ export async function getPaymentsByUser(
|
||||
productId: string,
|
||||
limit = 50
|
||||
): Promise<PaymentDoc[]> {
|
||||
const { resources } = await payContainer()
|
||||
.items.query<PaymentDoc>({
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@userId', value: userId },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return paymentsCollection().findMany({
|
||||
filter: { productId, userId },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPayment(doc: PaymentDoc): Promise<PaymentDoc> {
|
||||
const { resource } = await payContainer().items.create(doc);
|
||||
return resource as PaymentDoc;
|
||||
return paymentsCollection().create(doc);
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Telemetry repository — Cosmos DB CRUD for events, policies, and clusters.
|
||||
* Telemetry repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type {
|
||||
TelemetryEventDoc,
|
||||
TelemetryCollectionPolicyDoc,
|
||||
@ -10,31 +11,29 @@ import type {
|
||||
TelemetryQueryInput,
|
||||
} from './types.js';
|
||||
|
||||
// ─── Container accessors ────────────────────────────────────────────
|
||||
// ─── Collection accessors ───────────────────────────────────────────
|
||||
|
||||
function eventsContainer() {
|
||||
return getContainer('telemetry_events');
|
||||
function eventsCollection() {
|
||||
return getCollection<TelemetryEventDoc>('telemetry_events', '/pk');
|
||||
}
|
||||
|
||||
function policiesContainer() {
|
||||
return getContainer('telemetry_collection_policies');
|
||||
function policiesCollection() {
|
||||
return getCollection<TelemetryCollectionPolicyDoc>('telemetry_collection_policies', '/productId');
|
||||
}
|
||||
|
||||
function clustersContainer() {
|
||||
return getContainer('telemetry_error_clusters');
|
||||
function clustersCollection() {
|
||||
return getCollection<TelemetryErrorCluster>('telemetry_error_clusters', '/pk');
|
||||
}
|
||||
|
||||
// ─── Events ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertEvent(doc: TelemetryEventDoc): Promise<void> {
|
||||
await eventsContainer().items.upsert(doc);
|
||||
await eventsCollection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function upsertEventsBatch(docs: TelemetryEventDoc[]): Promise<void> {
|
||||
// Cosmos DB doesn't have native batch across partitions.
|
||||
// We upsert individually; for v1 this is acceptable.
|
||||
// TODO: Group by pk and use bulk operations for same-partition batches.
|
||||
const promises = docs.map(doc => eventsContainer().items.upsert(doc));
|
||||
const promises = docs.map(doc => eventsCollection().upsert(doc));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
@ -42,75 +41,31 @@ export async function queryEvents(
|
||||
productId: string,
|
||||
input: TelemetryQueryInput
|
||||
): Promise<{ events: TelemetryEventDoc[]; continuationToken?: string }> {
|
||||
const conditions: string[] = ['c.productId = @productId'];
|
||||
const parameters: Array<{ name: string; value: string | number | boolean }> = [
|
||||
{ name: '@productId', value: productId },
|
||||
];
|
||||
const filter: FilterMap = { productId };
|
||||
|
||||
if (input.userId) {
|
||||
conditions.push('c.userId = @userId');
|
||||
parameters.push({ name: '@userId', value: input.userId });
|
||||
}
|
||||
if (input.anonymousInstallId) {
|
||||
conditions.push('c.anonymousInstallId = @anonymousInstallId');
|
||||
parameters.push({ name: '@anonymousInstallId', value: input.anonymousInstallId });
|
||||
}
|
||||
if (input.platform) {
|
||||
conditions.push('c.platform = @platform');
|
||||
parameters.push({ name: '@platform', value: input.platform });
|
||||
}
|
||||
if (input.channel) {
|
||||
conditions.push('c.channel = @channel');
|
||||
parameters.push({ name: '@channel', value: input.channel });
|
||||
}
|
||||
if (input.osFamily) {
|
||||
conditions.push('c.osFamily = @osFamily');
|
||||
parameters.push({ name: '@osFamily', value: input.osFamily });
|
||||
}
|
||||
if (input.appVersion) {
|
||||
conditions.push('c.appVersion = @appVersion');
|
||||
parameters.push({ name: '@appVersion', value: input.appVersion });
|
||||
}
|
||||
if (input.buildNumber) {
|
||||
conditions.push('c.buildNumber = @buildNumber');
|
||||
parameters.push({ name: '@buildNumber', value: input.buildNumber });
|
||||
}
|
||||
if (input.module) {
|
||||
conditions.push('c.module = @module');
|
||||
parameters.push({ name: '@module', value: input.module });
|
||||
}
|
||||
if (input.eventName) {
|
||||
conditions.push('c.eventName = @eventName');
|
||||
parameters.push({ name: '@eventName', value: input.eventName });
|
||||
}
|
||||
if (input.eventType) {
|
||||
conditions.push('c.eventType = @eventType');
|
||||
parameters.push({ name: '@eventType', value: input.eventType });
|
||||
}
|
||||
if (input.from) {
|
||||
conditions.push('c.occurredAt >= @from');
|
||||
parameters.push({ name: '@from', value: input.from });
|
||||
}
|
||||
if (input.to) {
|
||||
conditions.push('c.occurredAt <= @to');
|
||||
parameters.push({ name: '@to', value: input.to });
|
||||
}
|
||||
if (input.userId) filter.userId = input.userId;
|
||||
if (input.anonymousInstallId) filter.anonymousInstallId = input.anonymousInstallId;
|
||||
if (input.platform) filter.platform = input.platform;
|
||||
if (input.channel) filter.channel = input.channel;
|
||||
if (input.osFamily) filter.osFamily = input.osFamily;
|
||||
if (input.appVersion) filter.appVersion = input.appVersion;
|
||||
if (input.buildNumber) filter.buildNumber = input.buildNumber;
|
||||
if (input.module) filter.module = input.module;
|
||||
if (input.eventName) filter.eventName = input.eventName;
|
||||
if (input.eventType) filter.eventType = input.eventType;
|
||||
if (input.from)
|
||||
filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: input.from };
|
||||
if (input.to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: input.to };
|
||||
|
||||
const query = `SELECT * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.occurredAt DESC`;
|
||||
// Note: continuationToken is not supported in the abstraction layer.
|
||||
// For v1, we fetch with limit only.
|
||||
const events = await eventsCollection().findMany({
|
||||
filter,
|
||||
sort: { occurredAt: -1 },
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
const iterator = eventsContainer().items.query<TelemetryEventDoc>(
|
||||
{ query, parameters },
|
||||
{
|
||||
maxItemCount: input.limit,
|
||||
continuationToken: input.continuationToken || undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const { resources, continuationToken } = await iterator.fetchNext();
|
||||
return {
|
||||
events: resources,
|
||||
continuationToken: continuationToken || undefined,
|
||||
};
|
||||
return { events, continuationToken: undefined };
|
||||
}
|
||||
|
||||
export async function queryGeoDistribution(
|
||||
@ -118,46 +73,38 @@ export async function queryGeoDistribution(
|
||||
from?: string,
|
||||
to?: string
|
||||
): Promise<Array<{ countryCode: string; count: number }>> {
|
||||
const conditions: string[] = [
|
||||
'c.productId = @productId',
|
||||
'IS_DEFINED(c.countryCode)',
|
||||
'c.countryCode != null',
|
||||
];
|
||||
const parameters: Array<{ name: string; value: string }> = [
|
||||
{ name: '@productId', value: productId },
|
||||
];
|
||||
if (from) {
|
||||
conditions.push('c.occurredAt >= @from');
|
||||
parameters.push({ name: '@from', value: from });
|
||||
}
|
||||
if (to) {
|
||||
conditions.push('c.occurredAt <= @to');
|
||||
parameters.push({ name: '@to', value: to });
|
||||
const filter: FilterMap = {
|
||||
productId,
|
||||
countryCode: { $exists: true },
|
||||
};
|
||||
if (from) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: from };
|
||||
if (to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: to };
|
||||
|
||||
// Fetch docs and aggregate in-memory
|
||||
const docs = await eventsCollection().findMany({ filter });
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const doc of docs) {
|
||||
if (doc.countryCode) {
|
||||
counts[doc.countryCode] = (counts[doc.countryCode] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const query = `SELECT c.countryCode, COUNT(1) AS count FROM c WHERE ${conditions.join(' AND ')} GROUP BY c.countryCode`;
|
||||
const { resources } = await eventsContainer()
|
||||
.items.query<{ countryCode: string; count: number }>({ query, parameters })
|
||||
.fetchAll();
|
||||
return resources.sort((a, b) => b.count - a.count);
|
||||
return Object.entries(counts)
|
||||
.map(([countryCode, count]) => ({ countryCode, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export async function deleteEventsByUserId(productId: string, userId: string): Promise<number> {
|
||||
// Find all events for this user, then delete them
|
||||
const { resources } = await eventsContainer()
|
||||
.items.query<{ id: string; pk: string }>({
|
||||
query: 'SELECT c.id, c.pk FROM c WHERE c.productId = @productId AND c.userId = @userId',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@userId', value: userId },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const docs = await eventsCollection().findMany({
|
||||
filter: { productId, userId },
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
for (const doc of resources) {
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
await eventsContainer().item(doc.id, doc.pk).delete();
|
||||
await eventsCollection().delete(doc.id, doc.pk);
|
||||
deleted++;
|
||||
} catch {
|
||||
// Skip docs that fail to delete (already deleted, etc.)
|
||||
@ -169,13 +116,10 @@ export async function deleteEventsByUserId(productId: string, userId: string): P
|
||||
// ─── Policies ───────────────────────────────────────────────────────
|
||||
|
||||
export async function listPolicies(productId: string): Promise<TelemetryCollectionPolicyDoc[]> {
|
||||
const { resources } = await policiesContainer()
|
||||
.items.query<TelemetryCollectionPolicyDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.priority DESC',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return policiesCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { priority: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPolicy(
|
||||
@ -183,10 +127,7 @@ export async function getPolicy(
|
||||
productId: string
|
||||
): Promise<TelemetryCollectionPolicyDoc | null> {
|
||||
try {
|
||||
const { resource } = await policiesContainer()
|
||||
.item(id, productId)
|
||||
.read<TelemetryCollectionPolicyDoc>();
|
||||
return resource ?? null;
|
||||
return await policiesCollection().findById(id, productId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -195,8 +136,7 @@ export async function getPolicy(
|
||||
export async function createPolicy(
|
||||
doc: TelemetryCollectionPolicyDoc
|
||||
): Promise<TelemetryCollectionPolicyDoc> {
|
||||
const { resource } = await policiesContainer().items.create(doc);
|
||||
return resource as TelemetryCollectionPolicyDoc;
|
||||
return policiesCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function updatePolicy(
|
||||
@ -205,13 +145,10 @@ export async function updatePolicy(
|
||||
updates: Partial<TelemetryCollectionPolicyDoc>
|
||||
): Promise<TelemetryCollectionPolicyDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await policiesContainer()
|
||||
.item(id, productId)
|
||||
.read<TelemetryCollectionPolicyDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await policiesContainer().item(id, productId).replace(merged);
|
||||
return resource as TelemetryCollectionPolicyDoc;
|
||||
return await policiesCollection().update(id, productId, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -219,7 +156,7 @@ export async function updatePolicy(
|
||||
|
||||
export async function deletePolicy(id: string, productId: string): Promise<boolean> {
|
||||
try {
|
||||
await policiesContainer().item(id, productId).delete();
|
||||
await policiesCollection().delete(id, productId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -238,45 +175,29 @@ export async function listClusters(
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<TelemetryErrorCluster[]> {
|
||||
const conditions: string[] = ['c.productId = @productId'];
|
||||
const parameters: Array<{ name: string; value: string | number | boolean }> = [
|
||||
{ name: '@productId', value: productId },
|
||||
];
|
||||
const filter: FilterMap = { productId };
|
||||
|
||||
if (filters?.platform) {
|
||||
conditions.push('c.platform = @platform');
|
||||
parameters.push({ name: '@platform', value: filters.platform });
|
||||
}
|
||||
if (filters?.module) {
|
||||
conditions.push('c.module = @module');
|
||||
parameters.push({ name: '@module', value: filters.module });
|
||||
}
|
||||
if (filters?.from) {
|
||||
conditions.push('c.lastSeenAt >= @from');
|
||||
parameters.push({ name: '@from', value: filters.from });
|
||||
}
|
||||
if (filters?.to) {
|
||||
conditions.push('c.lastSeenAt <= @to');
|
||||
parameters.push({ name: '@to', value: filters.to });
|
||||
}
|
||||
if (filters?.platform) filter.platform = filters.platform;
|
||||
if (filters?.module) filter.module = filters.module;
|
||||
if (filters?.from)
|
||||
filter.lastSeenAt = { ...((filter.lastSeenAt as object) ?? {}), $gte: filters.from };
|
||||
if (filters?.to)
|
||||
filter.lastSeenAt = { ...((filter.lastSeenAt as object) ?? {}), $lte: filters.to };
|
||||
|
||||
const limit = filters?.limit ?? 50;
|
||||
const query = `SELECT TOP ${limit} * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.totalCount DESC`;
|
||||
|
||||
const { resources } = await clustersContainer()
|
||||
.items.query<TelemetryErrorCluster>({ query, parameters })
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return clustersCollection().findMany({
|
||||
filter,
|
||||
sort: { totalCount: -1 },
|
||||
limit: filters?.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertCluster(cluster: TelemetryErrorCluster): Promise<void> {
|
||||
await clustersContainer().items.upsert(cluster);
|
||||
await clustersCollection().upsert(cluster);
|
||||
}
|
||||
|
||||
export async function getCluster(id: string, pk: string): Promise<TelemetryErrorCluster | null> {
|
||||
try {
|
||||
const { resource } = await clustersContainer().item(id, pk).read<TelemetryErrorCluster>();
|
||||
return resource ?? null;
|
||||
return await clustersCollection().findById(id, pk);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -288,13 +209,7 @@ export async function updateCluster(
|
||||
updates: Partial<TelemetryErrorCluster>
|
||||
): Promise<TelemetryErrorCluster | null> {
|
||||
try {
|
||||
const { resource: existing } = await clustersContainer()
|
||||
.item(id, pk)
|
||||
.read<TelemetryErrorCluster>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates };
|
||||
const { resource } = await clustersContainer().item(id, pk).replace(merged);
|
||||
return resource as unknown as TelemetryErrorCluster;
|
||||
return await clustersCollection().update(id, pk, updates);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,30 +1,11 @@
|
||||
/**
|
||||
* Repository tests for tokens module — mocked Cosmos DB.
|
||||
* Repository tests for tokens module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockFetchAll = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockRead = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: {
|
||||
query: () => ({ fetchAll: mockFetchAll }),
|
||||
create: mockCreate,
|
||||
},
|
||||
item: () => ({ read: mockRead, replace: mockReplace, delete: mockDelete }),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
default: { hash: vi.fn().mockResolvedValue('hashed_token') },
|
||||
}));
|
||||
|
||||
import { list, listByUser, getById, create, revoke, remove, countActive, hashToken } from './repository.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider } from '../../lib/datastore.js';
|
||||
import { list, listByUser, getById, create, revoke, remove, countActive } from './repository.js';
|
||||
import type { ApiTokenDoc } from './types.js';
|
||||
|
||||
const baseToken: ApiTokenDoc = {
|
||||
@ -44,19 +25,18 @@ const baseToken: ApiTokenDoc = {
|
||||
|
||||
describe('tokens repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns tokens without hashes', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseToken] });
|
||||
await create(baseToken);
|
||||
const result = await list('lysnrai');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).not.toHaveProperty('tokenHash');
|
||||
});
|
||||
|
||||
it('returns empty array when no tokens', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await list('lysnrai');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@ -64,7 +44,7 @@ describe('tokens repository', () => {
|
||||
|
||||
describe('listByUser', () => {
|
||||
it('returns tokens for user without hashes', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseToken] });
|
||||
await create(baseToken);
|
||||
const result = await listByUser('user_1', 'lysnrai');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).not.toHaveProperty('tokenHash');
|
||||
@ -73,19 +53,13 @@ describe('tokens repository', () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns token when found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseToken });
|
||||
await create(baseToken);
|
||||
const result = await getById('tok_1', 'user_1');
|
||||
expect(result).toEqual(baseToken);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('tok_1');
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await getById('tok_1', 'user_1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when resource is undefined', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await getById('tok_1', 'user_1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -93,7 +67,6 @@ describe('tokens repository', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns token without hash', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: baseToken });
|
||||
const result = await create(baseToken);
|
||||
expect(result).not.toHaveProperty('tokenHash');
|
||||
expect(result.id).toBe('tok_1');
|
||||
@ -102,14 +75,14 @@ describe('tokens repository', () => {
|
||||
|
||||
describe('revoke', () => {
|
||||
it('revokes an existing token', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseToken });
|
||||
mockReplace.mockResolvedValue({ resource: { ...baseToken, status: 'revoked' } });
|
||||
await create(baseToken);
|
||||
const result = await revoke('tok_1', 'user_1');
|
||||
expect(result).toBe(true);
|
||||
const updated = await getById('tok_1', 'user_1');
|
||||
expect(updated!.status).toBe('revoked');
|
||||
});
|
||||
|
||||
it('returns false when token not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await revoke('tok_1', 'user_1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@ -117,36 +90,22 @@ describe('tokens repository', () => {
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes and returns true', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await create(baseToken);
|
||||
const result = await remove('tok_1', 'user_1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
mockDelete.mockRejectedValue(new Error('Not found'));
|
||||
const result = await remove('tok_1', 'user_1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countActive', () => {
|
||||
it('returns count', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [5] });
|
||||
await create(baseToken);
|
||||
const result = await countActive('lysnrai');
|
||||
expect(result).toBe(5);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 when no results', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await countActive('lysnrai');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashToken', () => {
|
||||
it('hashes a token string', async () => {
|
||||
const result = await hashToken('raw_token');
|
||||
expect(result).toBe('hashed_token');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* API token repository — Cosmos DB.
|
||||
* API token repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { ApiTokenDoc, ApiTokenResponse } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('api_tokens');
|
||||
function collection() {
|
||||
return getCollection<ApiTokenDoc>('api_tokens', '/userId');
|
||||
}
|
||||
|
||||
function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
|
||||
@ -17,63 +17,46 @@ function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
|
||||
}
|
||||
|
||||
export async function list(productId: string, limit = 100): Promise<ApiTokenResponse[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ApiTokenDoc>({
|
||||
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 },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources.map(stripHash);
|
||||
const results = await collection().findMany({
|
||||
filter: { productId, status: { $ne: 'expired' } },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
return results.map(stripHash);
|
||||
}
|
||||
|
||||
export async function listByUser(userId: string, productId: string): Promise<ApiTokenResponse[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ApiTokenDoc>({
|
||||
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 },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources.map(stripHash);
|
||||
const results = await collection().findMany({
|
||||
filter: { productId, userId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
return results.map(stripHash);
|
||||
}
|
||||
|
||||
export async function getById(id: string, userId: string): Promise<ApiTokenDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<ApiTokenDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
|
||||
const { resource } = await container().items.create<ApiTokenDoc>(doc);
|
||||
return stripHash(resource!);
|
||||
const created = await collection().create(doc);
|
||||
return stripHash(created);
|
||||
}
|
||||
|
||||
export async function revoke(id: string, userId: string): Promise<boolean> {
|
||||
const existing = await getById(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 remove(id: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, userId).delete();
|
||||
await collection().delete(id, userId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -81,13 +64,7 @@ export async function remove(id: string, userId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function countActive(productId: string): Promise<number> {
|
||||
const { resources } = await container()
|
||||
.items.query<number>({
|
||||
query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status = 'active'",
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
return collection().count({ productId, status: 'active' });
|
||||
}
|
||||
|
||||
export async function hashToken(raw: string): Promise<string> {
|
||||
|
||||
@ -1,59 +1,43 @@
|
||||
/**
|
||||
* Repository tests for usage module — mocked Cosmos DB.
|
||||
* Repository tests for usage module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockFetchAll = vi.fn();
|
||||
const mockUpsert = vi.fn();
|
||||
const mockRead = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: {
|
||||
query: () => ({ fetchAll: mockFetchAll }),
|
||||
upsert: mockUpsert,
|
||||
},
|
||||
item: () => ({ read: mockRead }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider } from '../../lib/datastore.js';
|
||||
import { getByDate, list, upsert, getMonthlyUsage } from './repository.js';
|
||||
import type { UsageDoc } from './types.js';
|
||||
|
||||
// Use today's date so getMonthlyUsage picks it up
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const baseUsage: UsageDoc = {
|
||||
id: 'usg_2026-02-16_user_1',
|
||||
id: `usg_${todayStr}_user_1`,
|
||||
productId: 'lysnrai',
|
||||
userId: 'user_1',
|
||||
date: '2026-02-16',
|
||||
date: todayStr,
|
||||
dictations: 5,
|
||||
words: 250,
|
||||
durationMs: 30000,
|
||||
tokensUsed: 1200,
|
||||
costUsd: 0.01,
|
||||
createdAt: '2026-02-16T00:00:00Z',
|
||||
createdAt: `${todayStr}T00:00:00Z`,
|
||||
};
|
||||
|
||||
describe('usage repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
describe('getByDate', () => {
|
||||
it('returns usage when found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseUsage });
|
||||
const result = await getByDate('user_1', '2026-02-16');
|
||||
expect(result).toEqual(baseUsage);
|
||||
await upsert(baseUsage);
|
||||
const result = await getByDate('user_1', todayStr);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dictations).toBe(5);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await getByDate('user_1', '2026-02-16');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when resource is undefined', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await getByDate('user_1', '2026-02-16');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -61,19 +45,18 @@ describe('usage repository', () => {
|
||||
|
||||
describe('list', () => {
|
||||
it('returns usage records', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseUsage] });
|
||||
await upsert(baseUsage);
|
||||
const result = await list({ userId: 'user_1', productId: 'lysnrai' });
|
||||
expect(result).toEqual([baseUsage]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].userId).toBe('user_1');
|
||||
});
|
||||
|
||||
it('returns empty array when no records', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await list({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses default values for days and limit', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await list();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@ -81,30 +64,26 @@ describe('usage repository', () => {
|
||||
|
||||
describe('upsert', () => {
|
||||
it('upserts and returns usage', async () => {
|
||||
mockUpsert.mockResolvedValue({ resource: baseUsage });
|
||||
const result = await upsert(baseUsage);
|
||||
expect(result).toEqual(baseUsage);
|
||||
expect(result.id).toBe(baseUsage.id);
|
||||
expect(result.words).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthlyUsage', () => {
|
||||
it('returns aggregated monthly usage', async () => {
|
||||
mockFetchAll.mockResolvedValue({
|
||||
resources: [{ totalTokens: 5000, totalWords: 2500, totalDictations: 20 }],
|
||||
});
|
||||
await upsert(baseUsage);
|
||||
const result = await getMonthlyUsage('user_1', 'lysnrai');
|
||||
expect(result).toEqual({ tokens: 5000, words: 2500, dictations: 20 });
|
||||
expect(result).toEqual({ tokens: 1200, words: 250, dictations: 5 });
|
||||
});
|
||||
|
||||
it('returns zeros when no data', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [undefined] });
|
||||
const result = await getMonthlyUsage('user_1', 'lysnrai');
|
||||
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
|
||||
});
|
||||
|
||||
it('returns zeros when empty resources', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getMonthlyUsage('user_1', 'lysnrai');
|
||||
const result = await getMonthlyUsage('nonexistent', 'lysnrai');
|
||||
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
/**
|
||||
* Usage repository — Cosmos DB CRUD + aggregation.
|
||||
* Usage repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { UsageDoc, MonthlyUsage } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('usage_daily');
|
||||
function collection() {
|
||||
return getCollection<UsageDoc>('usage_daily', '/userId');
|
||||
}
|
||||
|
||||
export async function getByDate(userId: string, date: string): Promise<UsageDoc | null> {
|
||||
const id = `usg_${date}_${userId}`;
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<UsageDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -25,59 +24,43 @@ export async function list(
|
||||
const { userId, days = 30, limit = 100, productId } = options;
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
let queryText = 'SELECT * FROM c WHERE c.date >= @since';
|
||||
const parameters: { name: string; value: string | number }[] = [{ name: '@since', value: since }];
|
||||
// Fetch all matching docs and filter in-memory for $gte on date
|
||||
const filter: Record<string, unknown> = { date: { $gte: since } };
|
||||
if (productId) filter.productId = productId;
|
||||
if (userId) filter.userId = userId;
|
||||
|
||||
if (productId) {
|
||||
queryText += ' AND c.productId = @productId';
|
||||
parameters.push({ name: '@productId', value: productId });
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
queryText += ' AND c.userId = @userId';
|
||||
parameters.push({ name: '@userId', value: userId });
|
||||
}
|
||||
|
||||
queryText += ' ORDER BY c.date DESC OFFSET 0 LIMIT @limit';
|
||||
parameters.push({ name: '@limit', value: limit });
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<UsageDoc>({ query: queryText, parameters })
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: filter as import('@bytelyst/datastore').FilterMap,
|
||||
sort: { date: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsert(doc: UsageDoc): Promise<UsageDoc> {
|
||||
const { resource } = await container().items.upsert<UsageDoc>(doc);
|
||||
return resource!;
|
||||
return collection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function getMonthlyUsage(userId: string, productId: string): Promise<MonthlyUsage> {
|
||||
const now = new Date();
|
||||
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
|
||||
const query =
|
||||
'SELECT VALUE {' +
|
||||
' totalTokens: SUM(c.tokensUsed), ' +
|
||||
' totalWords: SUM(c.words), ' +
|
||||
' totalDictations: SUM(c.dictations)' +
|
||||
'} FROM c WHERE c.productId = @productId AND c.userId = @uid AND c.date >= @since';
|
||||
// Fetch docs for this month and aggregate in-memory
|
||||
const docs = await collection().findMany({
|
||||
filter: {
|
||||
productId,
|
||||
userId,
|
||||
date: { $gte: monthStart },
|
||||
} as import('@bytelyst/datastore').FilterMap,
|
||||
});
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<{ totalTokens: number; totalWords: number; totalDictations: number }>({
|
||||
query,
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@uid', value: userId },
|
||||
{ name: '@since', value: monthStart },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
let tokens = 0;
|
||||
let words = 0;
|
||||
let dictations = 0;
|
||||
for (const doc of docs) {
|
||||
tokens += doc.tokensUsed ?? 0;
|
||||
words += doc.words ?? 0;
|
||||
dictations += doc.dictations ?? 0;
|
||||
}
|
||||
|
||||
const row = resources[0];
|
||||
return {
|
||||
tokens: row?.totalTokens ?? 0,
|
||||
words: row?.totalWords ?? 0,
|
||||
dictations: row?.totalDictations ?? 0,
|
||||
};
|
||||
return { tokens, words, dictations };
|
||||
}
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
/**
|
||||
* Waitlist repository — Cosmos DB CRUD operations.
|
||||
* Waitlist repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*
|
||||
* Container: `waitlist`, partition key: `/email`
|
||||
* Collection: `waitlist`, partition key: `/email`
|
||||
* Cross-partition queries used for admin list/count/stats (acceptable for low-frequency reads).
|
||||
*/
|
||||
|
||||
import type { SqlParameter } from '@azure/cosmos';
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { WaitlistEntryDoc, WaitlistStatus, WaitlistSource } from './types.js';
|
||||
|
||||
const CONTAINER = 'waitlist';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<WaitlistEntryDoc>('waitlist', '/email');
|
||||
}
|
||||
|
||||
// ── Normalize email for case-insensitive dedup ──
|
||||
@ -24,47 +22,29 @@ export function normalizeEmail(email: string): string {
|
||||
// ── Create ──
|
||||
|
||||
export async function create(doc: WaitlistEntryDoc): Promise<WaitlistEntryDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as WaitlistEntryDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
// ── Read (single) ──
|
||||
|
||||
export async function getById(id: string): Promise<WaitlistEntryDoc | null> {
|
||||
// id-based lookup requires cross-partition query (partition is /email)
|
||||
const { resources } = await container()
|
||||
.items.query<WaitlistEntryDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.id = @id',
|
||||
parameters: [{ name: '@id', value: id }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
// Cross-partition lookup by id
|
||||
return collection().findOne({ filter: { id } });
|
||||
}
|
||||
|
||||
export async function getByEmail(
|
||||
emailNormalized: string,
|
||||
productId: string
|
||||
): Promise<WaitlistEntryDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<WaitlistEntryDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.emailNormalized = @email AND c.productId = @productId',
|
||||
parameters: [
|
||||
{ name: '@email', value: emailNormalized },
|
||||
{ name: '@productId', value: productId },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { emailNormalized, productId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByUnsubscribeToken(token: string): Promise<WaitlistEntryDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<WaitlistEntryDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.unsubscribeToken = @token',
|
||||
parameters: [{ name: '@token', value: token }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { unsubscribeToken: token },
|
||||
});
|
||||
}
|
||||
|
||||
// ── List (admin, cross-partition) ──
|
||||
@ -84,55 +64,32 @@ export async function list(opts: ListOptions): Promise<{
|
||||
items: WaitlistEntryDoc[];
|
||||
total: number;
|
||||
}> {
|
||||
const conditions: string[] = [];
|
||||
const params: SqlParameter[] = [];
|
||||
const filter: FilterMap = {};
|
||||
if (opts.productId) filter.productId = opts.productId;
|
||||
if (opts.status) filter.status = opts.status;
|
||||
if (opts.source) filter.source = opts.source;
|
||||
|
||||
if (opts.productId) {
|
||||
conditions.push('c.productId = @productId');
|
||||
params.push({ name: '@productId', value: opts.productId });
|
||||
}
|
||||
if (opts.status) {
|
||||
conditions.push('c.status = @status');
|
||||
params.push({ name: '@status', value: opts.status });
|
||||
}
|
||||
if (opts.source) {
|
||||
conditions.push('c.source = @source');
|
||||
params.push({ name: '@source', value: opts.source });
|
||||
}
|
||||
if (opts.q) {
|
||||
conditions.push('(CONTAINS(LOWER(c.email), @q) OR CONTAINS(LOWER(c.name), @q))');
|
||||
params.push({ name: '@q', value: opts.q.toLowerCase() });
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Allowed sort columns (whitelist to prevent injection)
|
||||
// Allowed sort columns (whitelist)
|
||||
const sortCol = ['position', 'priority', 'createdAt'].includes(opts.sortBy)
|
||||
? opts.sortBy
|
||||
: 'position';
|
||||
const sortDir = opts.sortOrder === 'desc' ? 'DESC' : 'ASC';
|
||||
const sortDir = opts.sortOrder === 'desc' ? -1 : 1;
|
||||
|
||||
// Count query
|
||||
const { resources: countRes } = await container()
|
||||
.items.query<number>({
|
||||
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
|
||||
parameters: [...params],
|
||||
})
|
||||
.fetchAll();
|
||||
const total = countRes[0] ?? 0;
|
||||
let allDocs = await collection().findMany({
|
||||
filter,
|
||||
sort: { [sortCol]: sortDir },
|
||||
});
|
||||
|
||||
// Data query
|
||||
const dataParams: SqlParameter[] = [
|
||||
...params,
|
||||
{ name: '@offset', value: opts.offset },
|
||||
{ name: '@limit', value: opts.limit },
|
||||
];
|
||||
const { resources: items } = await container()
|
||||
.items.query<WaitlistEntryDoc>({
|
||||
query: `SELECT * FROM c ${where} ORDER BY c.${sortCol} ${sortDir} OFFSET @offset LIMIT @limit`,
|
||||
parameters: dataParams,
|
||||
})
|
||||
.fetchAll();
|
||||
// In-memory text search
|
||||
if (opts.q) {
|
||||
const q = opts.q.toLowerCase();
|
||||
allDocs = allDocs.filter(
|
||||
d => d.email?.toLowerCase().includes(q) || d.name?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
const total = allDocs.length;
|
||||
const items = allDocs.slice(opts.offset, opts.offset + opts.limit);
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
@ -140,30 +97,21 @@ export async function list(opts: ListOptions): Promise<{
|
||||
// ── Count ──
|
||||
|
||||
export async function count(productId: string): Promise<number> {
|
||||
const { resources } = await container()
|
||||
.items.query<number>({
|
||||
query:
|
||||
'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status != @excluded',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@excluded', value: 'unsubscribed' },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
return collection().count({ productId, status: { $ne: 'unsubscribed' } });
|
||||
}
|
||||
|
||||
// ── Next position ──
|
||||
|
||||
export async function getNextPosition(productId: string): Promise<number> {
|
||||
const { resources } = await container()
|
||||
.items.query<number>({
|
||||
query: 'SELECT VALUE MAX(c.position) FROM c WHERE c.productId = @productId',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
const maxPos = resources[0] ?? 0;
|
||||
return (typeof maxPos === 'number' ? maxPos : 0) + 1;
|
||||
// Fetch all docs and find max position in-memory
|
||||
const docs = await collection().findMany({
|
||||
filter: { productId },
|
||||
});
|
||||
let maxPos = 0;
|
||||
for (const d of docs) {
|
||||
if (typeof d.position === 'number' && d.position > maxPos) maxPos = d.position;
|
||||
}
|
||||
return maxPos + 1;
|
||||
}
|
||||
|
||||
// ── Update ──
|
||||
@ -174,11 +122,10 @@ export async function update(
|
||||
updates: Partial<WaitlistEntryDoc>
|
||||
): Promise<WaitlistEntryDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, email).read<WaitlistEntryDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, email).replace(merged);
|
||||
return resource as WaitlistEntryDoc;
|
||||
return await collection().update(id, email, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -197,7 +144,7 @@ export async function unsubscribe(token: string): Promise<WaitlistEntryDoc | nul
|
||||
|
||||
export async function remove(id: string, email: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, email).delete();
|
||||
await collection().delete(id, email);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -212,18 +159,12 @@ export async function getByStatus(
|
||||
sortBy: 'position' | 'priority',
|
||||
limit: number
|
||||
): Promise<WaitlistEntryDoc[]> {
|
||||
const sortCol = sortBy === 'priority' ? 'c.priority DESC' : 'c.position ASC';
|
||||
const { resources } = await container()
|
||||
.items.query<WaitlistEntryDoc>({
|
||||
query: `SELECT * FROM c WHERE c.productId = @productId AND c.status = @status ORDER BY ${sortCol} OFFSET 0 LIMIT @limit`,
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@status', value: status },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
const sortDir = sortBy === 'priority' ? -1 : 1;
|
||||
return collection().findMany({
|
||||
filter: { productId, status },
|
||||
sort: { [sortBy]: sortDir },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Stats (admin analytics) ──
|
||||
@ -236,23 +177,20 @@ export interface WaitlistStats {
|
||||
}
|
||||
|
||||
export async function stats(productId: string): Promise<WaitlistStats> {
|
||||
const { resources } = await container()
|
||||
.items.query<WaitlistEntryDoc>({
|
||||
query: 'SELECT c.status, c.source, c.createdAt FROM c WHERE c.productId = @productId',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
const docs = await collection().findMany({
|
||||
filter: { productId },
|
||||
});
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
const bySource: Record<string, number> = {};
|
||||
const todayStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
let todaySignups = 0;
|
||||
|
||||
for (const entry of resources) {
|
||||
for (const entry of docs) {
|
||||
byStatus[entry.status] = (byStatus[entry.status] || 0) + 1;
|
||||
bySource[entry.source] = (bySource[entry.source] || 0) + 1;
|
||||
if (entry.createdAt.startsWith(todayStr)) todaySignups++;
|
||||
}
|
||||
|
||||
return { total: resources.length, byStatus, bySource, todaySignups };
|
||||
return { total: docs.length, byStatus, bySource, todaySignups };
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type {
|
||||
WebhookSubscriptionDoc,
|
||||
WebhookDeliveryDoc,
|
||||
@ -7,15 +7,14 @@ import type {
|
||||
UpdateWebhookSubscriptionInput,
|
||||
} from './types.js';
|
||||
|
||||
const SUBSCRIPTIONS_CONTAINER = 'webhook_subscriptions';
|
||||
const DELIVERIES_CONTAINER = 'webhook_deliveries';
|
||||
|
||||
function subscriptions() {
|
||||
return getContainer(SUBSCRIPTIONS_CONTAINER);
|
||||
function subsCollection() {
|
||||
return getCollection<WebhookSubscriptionDoc>('webhook_subscriptions', '/productId');
|
||||
}
|
||||
|
||||
function deliveries() {
|
||||
return getContainer(DELIVERIES_CONTAINER);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function deliveriesCollection(): import('@bytelyst/datastore').DocumentCollection<any> {
|
||||
// WebhookDeliveryDoc uses pk instead of productId — doesn't satisfy BaseDocument
|
||||
return getCollection('webhook_deliveries', '/pk');
|
||||
}
|
||||
|
||||
// ── Subscription CRUD ───────────────────────────────────────
|
||||
@ -38,8 +37,7 @@ export async function createSubscription(
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
};
|
||||
const { resource } = await subscriptions().items.create(doc);
|
||||
return resource as WebhookSubscriptionDoc;
|
||||
return subsCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function getSubscription(
|
||||
@ -47,21 +45,18 @@ export async function getSubscription(
|
||||
productId: string
|
||||
): Promise<WebhookSubscriptionDoc | undefined> {
|
||||
try {
|
||||
const { resource } = await subscriptions().item(id, productId).read<WebhookSubscriptionDoc>();
|
||||
return resource ?? undefined;
|
||||
const doc = await subsCollection().findById(id, productId);
|
||||
return doc ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSubscriptions(productId: string): Promise<WebhookSubscriptionDoc[]> {
|
||||
const { resources } = await subscriptions()
|
||||
.items.query<WebhookSubscriptionDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return subsCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSubscription(
|
||||
@ -72,21 +67,19 @@ export async function updateSubscription(
|
||||
const existing = await getSubscription(id, productId);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const updated: WebhookSubscriptionDoc = {
|
||||
...existing,
|
||||
const updates: Partial<WebhookSubscriptionDoc> = {
|
||||
...(input.url !== undefined && { url: input.url }),
|
||||
...(input.events !== undefined && { events: input.events }),
|
||||
...(input.enabled !== undefined && { enabled: input.enabled }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { resource } = await subscriptions().item(id, productId).replace(updated);
|
||||
return resource as WebhookSubscriptionDoc;
|
||||
return subsCollection().update(id, productId, updates);
|
||||
}
|
||||
|
||||
export async function deleteSubscription(id: string, productId: string): Promise<boolean> {
|
||||
try {
|
||||
await subscriptions().item(id, productId).delete();
|
||||
await subsCollection().delete(id, productId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -101,21 +94,25 @@ export async function incrementFailureCount(
|
||||
const existing = await getSubscription(id, productId);
|
||||
if (!existing) return;
|
||||
|
||||
existing.failureCount += 1;
|
||||
if (disable) existing.enabled = false;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
existing.lastDeliveryAt = existing.updatedAt;
|
||||
await subscriptions().item(id, productId).replace(existing);
|
||||
const now = new Date().toISOString();
|
||||
await subsCollection().update(id, productId, {
|
||||
failureCount: existing.failureCount + 1,
|
||||
...(disable && { enabled: false }),
|
||||
updatedAt: now,
|
||||
lastDeliveryAt: now,
|
||||
} as Partial<WebhookSubscriptionDoc>);
|
||||
}
|
||||
|
||||
export async function resetFailureCount(id: string, productId: string): Promise<void> {
|
||||
const existing = await getSubscription(id, productId);
|
||||
if (!existing) return;
|
||||
|
||||
existing.failureCount = 0;
|
||||
existing.lastDeliveryAt = new Date().toISOString();
|
||||
existing.updatedAt = existing.lastDeliveryAt;
|
||||
await subscriptions().item(id, productId).replace(existing);
|
||||
const now = new Date().toISOString();
|
||||
await subsCollection().update(id, productId, {
|
||||
failureCount: 0,
|
||||
lastDeliveryAt: now,
|
||||
updatedAt: now,
|
||||
} as Partial<WebhookSubscriptionDoc>);
|
||||
}
|
||||
|
||||
// ── Subscription lookup by event ────────────────────────────
|
||||
@ -124,29 +121,21 @@ export async function findSubscriptionsForEvent(
|
||||
productId: string,
|
||||
event: string
|
||||
): Promise<WebhookSubscriptionDoc[]> {
|
||||
const { resources } = await subscriptions()
|
||||
.items.query<WebhookSubscriptionDoc>({
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.enabled = true AND ARRAY_CONTAINS(c.events, @event)',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@event', value: event },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
// Fetch enabled subscriptions and filter for array contains in-memory
|
||||
const all = await subsCollection().findMany({
|
||||
filter: { productId, enabled: true },
|
||||
});
|
||||
return all.filter(s => (s.events as string[]).includes(event));
|
||||
}
|
||||
|
||||
// ── Delivery Log ────────────────────────────────────────────
|
||||
|
||||
export async function createDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> {
|
||||
const { resource } = await deliveries().items.create(doc);
|
||||
return resource as WebhookDeliveryDoc;
|
||||
return deliveriesCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function updateDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> {
|
||||
const { resource } = await deliveries().item(doc.id, doc.pk).replace(doc);
|
||||
return resource as WebhookDeliveryDoc;
|
||||
return deliveriesCollection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function listDeliveries(
|
||||
@ -154,14 +143,9 @@ export async function listDeliveries(
|
||||
options?: { limit?: number }
|
||||
): Promise<WebhookDeliveryDoc[]> {
|
||||
const limit = Math.min(options?.limit ?? 50, 200);
|
||||
const { resources } = await deliveries()
|
||||
.items.query<WebhookDeliveryDoc>({
|
||||
query: 'SELECT TOP @limit * FROM c WHERE STARTSWITH(c.pk, @prefix) ORDER BY c.createdAt DESC',
|
||||
parameters: [
|
||||
{ name: '@limit', value: limit },
|
||||
{ name: '@prefix', value: subscriptionId },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return deliveriesCollection().findMany({
|
||||
filter: { pk: { $startsWith: subscriptionId } },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user