feat(platform-service): migrate 7 more repositories to @bytelyst/datastore (exports, plans, status, sessions, referrals, ip-rules, delivery) — 753/753 tests pass

This commit is contained in:
saravanakumardb1 2026-03-02 00:54:55 -08:00
parent 4d126cb051
commit e355cb0c1b
9 changed files with 184 additions and 391 deletions

View File

@ -1,48 +1,35 @@
import { getContainer } from '../../lib/cosmos.js'; import type { FilterMap } from '@bytelyst/datastore';
import { getCollection } from '../../lib/datastore.js';
import type { DeliveryLogDoc } from './types.js'; import type { DeliveryLogDoc } from './types.js';
const CONTAINER = 'delivery_log'; function collection() {
return getCollection<DeliveryLogDoc>('delivery_log', '/pk');
function container() {
return getContainer(CONTAINER);
} }
export async function createDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLogDoc> { export async function createDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLogDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as DeliveryLogDoc;
} }
export async function updateDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLogDoc> { export async function updateDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLogDoc> {
const { resource } = await container().item(doc.id, doc.pk).replace(doc); return collection().upsert(doc);
return resource as DeliveryLogDoc;
} }
export async function listDeliveryLogs( export async function listDeliveryLogs(
productId: string, productId: string,
options?: { channel?: string; status?: string; limit?: number } options?: { channel?: string; status?: string; limit?: number }
): Promise<DeliveryLogDoc[]> { ): Promise<DeliveryLogDoc[]> {
const limit = options?.limit ?? 50; const limit = Math.min(options?.limit ?? 50, 200);
let query = 'SELECT TOP @limit * FROM c WHERE STARTSWITH(c.pk, @prefix)'; const filter: FilterMap = {
const parameters: Array<{ name: string; value: string | number }> = [ pk: { $startsWith: productId },
{ name: '@limit', value: Math.min(limit, 200) }, };
{ name: '@prefix', value: productId }, if (options?.channel) filter.channel = options.channel;
]; if (options?.status) filter.status = options.status;
if (options?.channel) { return collection().findMany({
query += ' AND c.channel = @channel'; filter,
parameters.push({ name: '@channel', value: options.channel }); sort: { createdAt: -1 },
} limit,
if (options?.status) { });
query += ' AND c.status = @status';
parameters.push({ name: '@status', value: options.status });
}
query += ' ORDER BY c.createdAt DESC';
const { resources } = await container()
.items.query<DeliveryLogDoc>({ query, parameters })
.fetchAll();
return resources;
} }
export async function getDeliveryStats(productId: string): Promise<{ export async function getDeliveryStats(productId: string): Promise<{
@ -51,32 +38,21 @@ export async function getDeliveryStats(productId: string): Promise<{
failed: number; failed: number;
byChannel: Record<string, number>; byChannel: Record<string, number>;
}> { }> {
const prefix = productId; // Fetch all matching docs and aggregate in-memory
const docs = await collection().findMany({
const countQuery = await container() filter: { pk: { $startsWith: productId } },
.items.query<{ status: string; cnt: number }>({ });
query:
'SELECT c.status, COUNT(1) AS cnt FROM c WHERE STARTSWITH(c.pk, @prefix) GROUP BY c.status',
parameters: [{ name: '@prefix', value: prefix }],
})
.fetchAll();
const channelQuery = await container()
.items.query<{ channel: string; cnt: number }>({
query:
'SELECT c.channel, COUNT(1) AS cnt FROM c WHERE STARTSWITH(c.pk, @prefix) GROUP BY c.channel',
parameters: [{ name: '@prefix', value: prefix }],
})
.fetchAll();
const byStatus: Record<string, number> = {}; const byStatus: Record<string, number> = {};
for (const r of countQuery.resources) byStatus[r.status] = r.cnt;
const byChannel: Record<string, number> = {}; const byChannel: Record<string, number> = {};
for (const r of channelQuery.resources) byChannel[r.channel] = r.cnt;
for (const doc of docs) {
byStatus[doc.status] = (byStatus[doc.status] ?? 0) + 1;
byChannel[doc.channel] = (byChannel[doc.channel] ?? 0) + 1;
}
return { return {
total: Object.values(byStatus).reduce((a, b) => a + b, 0), total: docs.length,
sent: byStatus['sent'] ?? 0, sent: byStatus['sent'] ?? 0,
failed: byStatus['failed'] ?? 0, failed: byStatus['failed'] ?? 0,
byChannel, byChannel,

View File

@ -1,44 +1,30 @@
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { ExportJobDoc } from './types.js'; import type { ExportJobDoc } from './types.js';
const CONTAINER = 'export_jobs'; function collection() {
return getCollection<ExportJobDoc>('export_jobs', '/productId');
function container() {
return getContainer(CONTAINER);
} }
export async function createExportJob(doc: ExportJobDoc): Promise<ExportJobDoc> { export async function createExportJob(doc: ExportJobDoc): Promise<ExportJobDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as ExportJobDoc;
} }
export async function getExportJob(id: string, productId: string): Promise<ExportJobDoc | null> { export async function getExportJob(id: string, productId: string): Promise<ExportJobDoc | null> {
try { try {
const { resource } = await container().item(id, productId).read<ExportJobDoc>(); return await collection().findById(id, productId);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function updateExportJob(doc: ExportJobDoc): Promise<ExportJobDoc> { export async function updateExportJob(doc: ExportJobDoc): Promise<ExportJobDoc> {
const { resource } = await container().item(doc.id, doc.productId).replace(doc); return collection().upsert(doc);
return resource as ExportJobDoc;
} }
export async function listExportJobs(productId: string, limit = 20): Promise<ExportJobDoc[]> { export async function listExportJobs(productId: string, limit = 20): Promise<ExportJobDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<ExportJobDoc>( filter: { productId },
{ sort: { createdAt: -1 },
query: limit,
'SELECT TOP @limit * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@limit', value: limit },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }

View File

@ -1,51 +1,34 @@
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { IPRuleDoc } from './types.js'; import type { IPRuleDoc } from './types.js';
const CONTAINER = 'ip_rules'; function collection() {
return getCollection<IPRuleDoc>('ip_rules', '/productId');
function container() {
return getContainer(CONTAINER);
} }
export async function listRules(productId: string): Promise<IPRuleDoc[]> { export async function listRules(productId: string): Promise<IPRuleDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<IPRuleDoc>( filter: { productId },
{ sort: { createdAt: -1 },
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC', });
parameters: [{ name: '@productId', value: productId }],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }
export async function getActiveRules(productId: string): Promise<IPRuleDoc[]> { export async function getActiveRules(productId: string): Promise<IPRuleDoc[]> {
// Fetch all rules, then filter in-memory for the OR condition
// (expiresAt not defined OR expiresAt > now)
const allRules = await collection().findMany({
filter: { productId },
});
const now = new Date().toISOString(); const now = new Date().toISOString();
const { resources } = await container() return allRules.filter(r => !r.expiresAt || r.expiresAt > now);
.items.query<IPRuleDoc>(
{
query:
'SELECT * FROM c WHERE c.productId = @productId AND (NOT IS_DEFINED(c.expiresAt) OR c.expiresAt > @now)',
parameters: [
{ name: '@productId', value: productId },
{ name: '@now', value: now },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }
export async function createRule(doc: IPRuleDoc): Promise<IPRuleDoc> { export async function createRule(doc: IPRuleDoc): Promise<IPRuleDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as IPRuleDoc;
} }
export async function deleteRule(id: string, productId: string): Promise<boolean> { export async function deleteRule(id: string, productId: string): Promise<boolean> {
try { try {
await container().item(id, productId).delete(); await collection().delete(id, productId);
return true; return true;
} catch { } catch {
return false; return false;

View File

@ -1,24 +1,10 @@
/** /**
* Repository tests for plans module mocked Cosmos DB. * Repository tests for plans module in-memory datastore.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
const mockFetchAll = vi.fn(); import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
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 { list, getByName, create, update, getDefaults } from './repository.js'; import { list, getByName, create, update, getDefaults } from './repository.js';
import type { PlanConfig } from './types.js'; import type { PlanConfig } from './types.js';
@ -39,18 +25,22 @@ const basePlan: PlanConfig = {
describe('plans repository', () => { describe('plans repository', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); setProvider(new MemoryDatastoreProvider());
});
afterAll(() => {
_resetDatastoreProvider();
}); });
describe('list', () => { describe('list', () => {
it('returns plans from Cosmos', async () => { it('returns plans from datastore', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePlan] }); await create(basePlan);
const result = await list('lysnrai'); const result = await list('lysnrai');
expect(result).toEqual([basePlan]); expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('pro');
}); });
it('returns defaults when no plans in DB', async () => { it('returns defaults when no plans in DB', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list('lysnrai'); const result = await list('lysnrai');
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
expect(result[0].name).toBe('free'); expect(result[0].name).toBe('free');
@ -61,13 +51,12 @@ describe('plans repository', () => {
describe('getByName', () => { describe('getByName', () => {
it('returns plan when found', async () => { it('returns plan when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePlan] }); await create(basePlan);
const result = await getByName('pro', 'lysnrai'); const result = await getByName('pro', 'lysnrai');
expect(result).toEqual(basePlan); expect(result).toEqual(basePlan);
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByName('nonexistent', 'lysnrai'); const result = await getByName('nonexistent', 'lysnrai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -75,7 +64,6 @@ describe('plans repository', () => {
describe('create', () => { describe('create', () => {
it('creates and returns plan', async () => { it('creates and returns plan', async () => {
mockCreate.mockResolvedValue({ resource: basePlan });
const result = await create(basePlan); const result = await create(basePlan);
expect(result).toEqual(basePlan); expect(result).toEqual(basePlan);
}); });
@ -83,21 +71,13 @@ describe('plans repository', () => {
describe('update', () => { describe('update', () => {
it('merges updates and returns plan', async () => { it('merges updates and returns plan', async () => {
mockRead.mockResolvedValue({ resource: basePlan }); await create(basePlan);
const updated = { ...basePlan, price: 14.99 };
mockReplace.mockResolvedValue({ resource: updated });
const result = await update('plan_lysnrai_pro', { price: 14.99 }); const result = await update('plan_lysnrai_pro', { price: 14.99 });
expect(result).toEqual(updated); expect(result).not.toBeNull();
expect(result!.price).toBe(14.99);
}); });
it('returns null when plan not found', async () => { it('returns null when plan not found', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await update('nonexistent', { price: 14.99 });
expect(result).toBeNull();
});
it('returns null on error', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await update('nonexistent', { price: 14.99 }); const result = await update('nonexistent', { price: 14.99 });
expect(result).toBeNull(); expect(result).toBeNull();
}); });

View File

@ -1,55 +1,41 @@
/** /**
* Plans repository Cosmos DB CRUD for plan configurations. * Plans repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { PlanConfig } from './types.js'; import type { PlanConfig } from './types.js';
import { DEFAULT_PLANS } from './types.js'; import { DEFAULT_PLANS } from './types.js';
function container() { function collection() {
return getContainer('plans'); return getCollection<PlanConfig>('plans', '/productId');
} }
export async function list(productId: string): Promise<PlanConfig[]> { export async function list(productId: string): Promise<PlanConfig[]> {
const { resources } = await container() const results = await collection().findMany({
.items.query<PlanConfig>({ filter: { productId },
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.price ASC', sort: { price: 1 },
parameters: [{ name: '@productId', value: productId }], });
})
.fetchAll();
// If no plans in DB yet, return defaults // If no plans in DB yet, return defaults
if (resources.length === 0) { if (results.length === 0) {
return getDefaults(productId); return getDefaults(productId);
} }
return resources; return results;
} }
export async function getByName(name: string, productId: string): Promise<PlanConfig | null> { export async function getByName(name: string, productId: string): Promise<PlanConfig | null> {
const { resources } = await container() return collection().findOne({
.items.query<PlanConfig>({ filter: { productId, name },
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.name = @name', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@name', value: name },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function create(doc: PlanConfig): Promise<PlanConfig> { export async function create(doc: PlanConfig): Promise<PlanConfig> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as PlanConfig;
} }
export async function update(id: string, updates: Partial<PlanConfig>): Promise<PlanConfig | null> { export async function update(id: string, updates: Partial<PlanConfig>): Promise<PlanConfig | null> {
try { try {
const { resource: existing } = await container().item(id, id).read<PlanConfig>(); return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, id).replace(merged);
return resource as PlanConfig;
} catch { } catch {
return null; return null;
} }

View File

@ -1,24 +1,10 @@
/** /**
* Repository tests for referrals module mocked Cosmos DB. * Repository tests for referrals module in-memory datastore.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
const mockFetchAll = vi.fn(); import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
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 { import {
listAll, listAll,
getByReferrer, getByReferrer,
@ -48,40 +34,44 @@ const baseReferral: ReferralDoc = {
describe('referrals repository', () => { describe('referrals repository', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); setProvider(new MemoryDatastoreProvider());
});
afterAll(() => {
_resetDatastoreProvider();
}); });
describe('listAll', () => { describe('listAll', () => {
it('returns referrals', async () => { it('returns referrals', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseReferral] }); await create(baseReferral);
const result = await listAll(100, 0, 'lysnrai'); const result = await listAll(100, 0, 'lysnrai');
expect(result).toEqual([baseReferral]); expect(result).toHaveLength(1);
expect(result[0]!.id).toBe('ref_1');
}); });
it('returns empty array when no referrals', async () => { it('returns empty array when no referrals', async () => {
mockFetchAll.mockResolvedValue({ resources: [] }); const result = await listAll(100, 0, 'lysnrai');
const result = await listAll();
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
}); });
describe('getByReferrer', () => { describe('getByReferrer', () => {
it('returns referrals for referrer', async () => { it('returns referrals for referrer', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseReferral] }); await create(baseReferral);
const result = await getByReferrer('user_1', 'lysnrai'); const result = await getByReferrer('user_1', 'lysnrai');
expect(result).toEqual([baseReferral]); expect(result).toHaveLength(1);
expect(result[0]!.referrerId).toBe('user_1');
}); });
}); });
describe('getByReferredEmail', () => { describe('getByReferredEmail', () => {
it('returns referral when found', async () => { it('returns referral when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseReferral] }); await create(baseReferral);
const result = await getByReferredEmail('new@example.com', 'lysnrai'); const result = await getByReferredEmail('new@example.com', 'lysnrai');
expect(result).toEqual(baseReferral); expect(result).toEqual(baseReferral);
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByReferredEmail('none@example.com', 'lysnrai'); const result = await getByReferredEmail('none@example.com', 'lysnrai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -89,27 +79,19 @@ describe('referrals repository', () => {
describe('getById', () => { describe('getById', () => {
it('returns referral when found', async () => { it('returns referral when found', async () => {
mockRead.mockResolvedValue({ resource: baseReferral }); await create(baseReferral);
const result = await getById('ref_1', 'user_1'); const result = await getById('ref_1', 'user_1');
expect(result).toEqual(baseReferral); expect(result).toEqual(baseReferral);
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found')); const result = await getById('nonexistent', 'user_1');
const result = await getById('ref_1', 'user_1');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getById('ref_1', 'user_1');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe('create', () => { describe('create', () => {
it('creates and returns referral', async () => { it('creates and returns referral', async () => {
mockCreate.mockResolvedValue({ resource: baseReferral });
const result = await create(baseReferral); const result = await create(baseReferral);
expect(result).toEqual(baseReferral); expect(result).toEqual(baseReferral);
}); });
@ -117,41 +99,30 @@ describe('referrals repository', () => {
describe('update', () => { describe('update', () => {
it('merges updates and returns referral', async () => { it('merges updates and returns referral', async () => {
mockRead.mockResolvedValue({ resource: baseReferral }); await create(baseReferral);
const updated = { ...baseReferral, status: 'signed_up' as const };
mockReplace.mockResolvedValue({ resource: updated });
const result = await update('ref_1', 'user_1', { status: 'signed_up' }); const result = await update('ref_1', 'user_1', { status: 'signed_up' });
expect(result).toEqual(updated); expect(result).not.toBeNull();
expect(result!.status).toBe('signed_up');
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockRead.mockResolvedValue({ resource: undefined }); const result = await update('nonexistent', 'user_1', { status: 'signed_up' });
const result = await update('ref_1', 'user_1', { status: 'signed_up' });
expect(result).toBeNull();
});
it('returns null on error', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await update('ref_1', 'user_1', { status: 'signed_up' });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe('countReferrals', () => { describe('countReferrals', () => {
it('returns counts', async () => { it('returns counts', async () => {
mockFetchAll await create(baseReferral);
.mockResolvedValueOnce({ resources: [10] }) await create({ ...baseReferral, id: 'ref_2', status: 'signed_up' });
.mockResolvedValueOnce({ resources: [5] }) await create({ ...baseReferral, id: 'ref_3', status: 'rewarded' });
.mockResolvedValueOnce({ resources: [2] });
const result = await countReferrals('lysnrai'); const result = await countReferrals('lysnrai');
expect(result).toEqual({ total: 10, completed: 5, rewarded: 2 }); expect(result.total).toBe(3);
expect(result.completed).toBe(2); // signed_up + rewarded
expect(result.rewarded).toBe(1);
}); });
it('returns zeros when no data', async () => { it('returns zeros when no data', async () => {
mockFetchAll
.mockResolvedValueOnce({ resources: [] })
.mockResolvedValueOnce({ resources: [] })
.mockResolvedValueOnce({ resources: [] });
const result = await countReferrals('lysnrai'); const result = await countReferrals('lysnrai');
expect(result).toEqual({ total: 0, completed: 0, rewarded: 0 }); expect(result).toEqual({ total: 0, completed: 0, rewarded: 0 });
}); });

View File

@ -1,75 +1,51 @@
/** /**
* Referrals repository Cosmos DB CRUD operations. * Referrals repository cloud-agnostic via @bytelyst/datastore.
* Consolidated from admin-dashboard-web + user-dashboard-web repos. * Consolidated from admin-dashboard-web + user-dashboard-web repos.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { ReferralDoc } from './types.js'; import type { ReferralDoc } from './types.js';
const CONTAINER = 'referrals'; function collection() {
return getCollection<ReferralDoc>('referrals', '/referrerId');
function container() {
return getContainer(CONTAINER);
} }
export async function listAll(limit = 100, offset = 0, productId?: string): Promise<ReferralDoc[]> { export async function listAll(limit = 100, offset = 0, productId?: string): Promise<ReferralDoc[]> {
const { resources } = await container() const results = await collection().findMany({
.items.query<ReferralDoc>({ filter: { productId: productId ?? '' },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', limit: limit + offset,
parameters: [ });
{ name: '@productId', value: productId ?? '' }, return results.slice(offset);
{ name: '@offset', value: offset },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
} }
export async function getByReferrer(referrerId: string, productId: string): Promise<ReferralDoc[]> { export async function getByReferrer(referrerId: string, productId: string): Promise<ReferralDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<ReferralDoc>({ filter: { productId, referrerId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.referrerId = @rid ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@rid', value: referrerId },
],
})
.fetchAll();
return resources;
} }
export async function getByReferredEmail( export async function getByReferredEmail(
email: string, email: string,
productId: string productId: string
): Promise<ReferralDoc | null> { ): Promise<ReferralDoc | null> {
const { resources } = await container() return collection().findOne({
.items.query<ReferralDoc>({ filter: { productId, referredEmail: email.toLowerCase() },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.referredEmail = @email ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@email', value: email.toLowerCase() },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getById(id: string, referrerId: string): Promise<ReferralDoc | null> { export async function getById(id: string, referrerId: string): Promise<ReferralDoc | null> {
try { try {
const { resource } = await container().item(id, referrerId).read<ReferralDoc>(); return await collection().findById(id, referrerId);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function create(doc: ReferralDoc): Promise<ReferralDoc> { export async function create(doc: ReferralDoc): Promise<ReferralDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as ReferralDoc;
} }
export async function update( export async function update(
@ -78,11 +54,7 @@ export async function update(
updates: Partial<ReferralDoc> updates: Partial<ReferralDoc>
): Promise<ReferralDoc | null> { ): Promise<ReferralDoc | null> {
try { try {
const { resource: existing } = await container().item(id, referrerId).read<ReferralDoc>(); return await collection().update(id, referrerId, updates);
if (!existing) return null;
const merged = { ...existing, ...updates };
const { resource } = await container().item(id, referrerId).replace(merged);
return resource as ReferralDoc;
} catch { } catch {
return null; return null;
} }
@ -93,29 +65,14 @@ export async function countReferrals(productId: string): Promise<{
completed: number; completed: number;
rewarded: number; rewarded: number;
}> { }> {
const { resources: totalRes } = await container() const total = await collection().count({ productId });
.items.query<number>({ const completed = await collection().count({
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId', productId,
parameters: [{ name: '@productId', value: productId }], status: { $in: ['signed_up', 'subscribed', 'rewarded'] },
}) });
.fetchAll(); const rewarded = await collection().count({
const { resources: completedRes } = await container() productId,
.items.query<number>({ status: 'rewarded',
query: });
"SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status IN ('signed_up', 'subscribed', 'rewarded')", return { total, completed, rewarded };
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
const { resources: rewardedRes } = await container()
.items.query<number>({
query:
"SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status = 'rewarded'",
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return {
total: totalRes[0] ?? 0,
completed: completedRes[0] ?? 0,
rewarded: rewardedRes[0] ?? 0,
};
} }

View File

@ -1,63 +1,43 @@
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { SessionDoc } from './types.js'; import type { SessionDoc } from './types.js';
const CONTAINER = 'sessions'; function collection() {
return getCollection<SessionDoc>('sessions', '/userId');
function container() {
return getContainer(CONTAINER);
} }
export async function createSession(doc: SessionDoc): Promise<SessionDoc> { export async function createSession(doc: SessionDoc): Promise<SessionDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as SessionDoc;
} }
export async function getSession(id: string, userId: string): Promise<SessionDoc | null> { export async function getSession(id: string, userId: string): Promise<SessionDoc | null> {
try { try {
const { resource } = await container().item(id, userId).read<SessionDoc>(); return await collection().findById(id, userId);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function listUserSessions(userId: string): Promise<SessionDoc[]> { export async function listUserSessions(userId: string): Promise<SessionDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<SessionDoc>( filter: { userId, revokedAt: { $exists: false } },
{ sort: { lastActiveAt: -1 },
query: });
'SELECT * FROM c WHERE c.userId = @userId AND NOT IS_DEFINED(c.revokedAt) ORDER BY c.lastActiveAt DESC',
parameters: [{ name: '@userId', value: userId }],
},
{ partitionKey: userId }
)
.fetchAll();
return resources;
} }
export async function listAllUserSessions(userId: string): Promise<SessionDoc[]> { export async function listAllUserSessions(userId: string): Promise<SessionDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<SessionDoc>( filter: { userId },
{ sort: { createdAt: -1 },
query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.createdAt DESC', });
parameters: [{ name: '@userId', value: userId }],
},
{ partitionKey: userId }
)
.fetchAll();
return resources;
} }
export async function revokeSession(id: string, userId: string): Promise<boolean> { export async function revokeSession(id: string, userId: string): Promise<boolean> {
const session = await getSession(id, userId); const session = await getSession(id, userId);
if (!session || session.revokedAt) return false; if (!session || session.revokedAt) return false;
await container() await collection().update(id, userId, {
.item(id, userId) revokedAt: new Date().toISOString(),
.replace({ } as Partial<SessionDoc>);
...session,
revokedAt: new Date().toISOString(),
});
return true; return true;
} }
@ -68,12 +48,9 @@ export async function revokeAllUserSessions(userId: string): Promise<number> {
for (const session of sessions) { for (const session of sessions) {
try { try {
await container() await collection().update(session.id, userId, {
.item(session.id, userId) revokedAt: now,
.replace({ } as Partial<SessionDoc>);
...session,
revokedAt: now,
});
revoked++; revoked++;
} catch { } catch {
// best-effort // best-effort
@ -87,12 +64,9 @@ export async function touchSession(id: string, userId: string): Promise<void> {
const session = await getSession(id, userId); const session = await getSession(id, userId);
if (!session || session.revokedAt) return; if (!session || session.revokedAt) return;
await container() await collection().update(id, userId, {
.item(id, userId) lastActiveAt: new Date().toISOString(),
.replace({ } as Partial<SessionDoc>);
...session,
lastActiveAt: new Date().toISOString(),
});
} }
export async function isSessionRevoked(id: string, userId: string): Promise<boolean> { export async function isSessionRevoked(id: string, userId: string): Promise<boolean> {

View File

@ -1,56 +1,36 @@
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import { NotFoundError } from '../../lib/errors.js'; import { NotFoundError } from '../../lib/errors.js';
import type { IncidentDoc } from './types.js'; import type { IncidentDoc } from './types.js';
const CONTAINER = 'incidents'; function collection() {
return getCollection<IncidentDoc>('incidents', '/productId');
function container() {
return getContainer(CONTAINER);
} }
export async function listIncidents(productId: string, limit = 20): Promise<IncidentDoc[]> { export async function listIncidents(productId: string, limit = 20): Promise<IncidentDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<IncidentDoc>( filter: { productId },
{ sort: { createdAt: -1 },
query: limit,
'SELECT TOP @limit * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@limit', value: limit },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }
export async function listActiveIncidents(productId: string): Promise<IncidentDoc[]> { export async function listActiveIncidents(productId: string): Promise<IncidentDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<IncidentDoc>( filter: { productId, status: { $ne: 'resolved' } },
{ sort: { createdAt: -1 },
query: });
'SELECT * FROM c WHERE c.productId = @productId AND c.status != "resolved" ORDER BY c.createdAt DESC',
parameters: [{ name: '@productId', value: productId }],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }
export async function getIncident(id: string, productId: string): Promise<IncidentDoc> { export async function getIncident(id: string, productId: string): Promise<IncidentDoc> {
const { resource } = await container().item(id, productId).read<IncidentDoc>(); const doc = await collection().findById(id, productId);
if (!resource) throw new NotFoundError(`Incident '${id}' not found`); if (!doc) throw new NotFoundError(`Incident '${id}' not found`);
return resource; return doc;
} }
export async function createIncident(doc: IncidentDoc): Promise<IncidentDoc> { export async function createIncident(doc: IncidentDoc): Promise<IncidentDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as IncidentDoc;
} }
export async function updateIncident(doc: IncidentDoc): Promise<IncidentDoc> { export async function updateIncident(doc: IncidentDoc): Promise<IncidentDoc> {
const { resource } = await container().item(doc.id, doc.productId).replace(doc); return collection().upsert(doc);
return resource as IncidentDoc;
} }