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:
parent
4d126cb051
commit
e355cb0c1b
@ -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';
|
||||
|
||||
const CONTAINER = 'delivery_log';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<DeliveryLogDoc>('delivery_log', '/pk');
|
||||
}
|
||||
|
||||
export async function createDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLogDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as DeliveryLogDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function updateDeliveryLog(doc: DeliveryLogDoc): Promise<DeliveryLogDoc> {
|
||||
const { resource } = await container().item(doc.id, doc.pk).replace(doc);
|
||||
return resource as DeliveryLogDoc;
|
||||
return collection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function listDeliveryLogs(
|
||||
productId: string,
|
||||
options?: { channel?: string; status?: string; limit?: number }
|
||||
): Promise<DeliveryLogDoc[]> {
|
||||
const limit = options?.limit ?? 50;
|
||||
let query = 'SELECT TOP @limit * FROM c WHERE STARTSWITH(c.pk, @prefix)';
|
||||
const parameters: Array<{ name: string; value: string | number }> = [
|
||||
{ name: '@limit', value: Math.min(limit, 200) },
|
||||
{ name: '@prefix', value: productId },
|
||||
];
|
||||
const limit = Math.min(options?.limit ?? 50, 200);
|
||||
const filter: FilterMap = {
|
||||
pk: { $startsWith: productId },
|
||||
};
|
||||
if (options?.channel) filter.channel = options.channel;
|
||||
if (options?.status) filter.status = options.status;
|
||||
|
||||
if (options?.channel) {
|
||||
query += ' AND c.channel = @channel';
|
||||
parameters.push({ name: '@channel', value: options.channel });
|
||||
}
|
||||
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;
|
||||
return collection().findMany({
|
||||
filter,
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDeliveryStats(productId: string): Promise<{
|
||||
@ -51,32 +38,21 @@ export async function getDeliveryStats(productId: string): Promise<{
|
||||
failed: number;
|
||||
byChannel: Record<string, number>;
|
||||
}> {
|
||||
const prefix = productId;
|
||||
|
||||
const countQuery = await container()
|
||||
.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();
|
||||
// Fetch all matching docs and aggregate in-memory
|
||||
const docs = await collection().findMany({
|
||||
filter: { pk: { $startsWith: productId } },
|
||||
});
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
for (const r of countQuery.resources) byStatus[r.status] = r.cnt;
|
||||
|
||||
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 {
|
||||
total: Object.values(byStatus).reduce((a, b) => a + b, 0),
|
||||
total: docs.length,
|
||||
sent: byStatus['sent'] ?? 0,
|
||||
failed: byStatus['failed'] ?? 0,
|
||||
byChannel,
|
||||
|
||||
@ -1,44 +1,30 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { ExportJobDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'export_jobs';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<ExportJobDoc>('export_jobs', '/productId');
|
||||
}
|
||||
|
||||
export async function createExportJob(doc: ExportJobDoc): Promise<ExportJobDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as ExportJobDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function getExportJob(id: string, productId: string): Promise<ExportJobDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, productId).read<ExportJobDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, productId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExportJob(doc: ExportJobDoc): Promise<ExportJobDoc> {
|
||||
const { resource } = await container().item(doc.id, doc.productId).replace(doc);
|
||||
return resource as ExportJobDoc;
|
||||
return collection().upsert(doc);
|
||||
}
|
||||
|
||||
export async function listExportJobs(productId: string, limit = 20): Promise<ExportJobDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ExportJobDoc>(
|
||||
{
|
||||
query:
|
||||
'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;
|
||||
return collection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,51 +1,34 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { IPRuleDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'ip_rules';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<IPRuleDoc>('ip_rules', '/productId');
|
||||
}
|
||||
|
||||
export async function listRules(productId: string): Promise<IPRuleDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<IPRuleDoc>(
|
||||
{
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
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 { resources } = await container()
|
||||
.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;
|
||||
return allRules.filter(r => !r.expiresAt || r.expiresAt > now);
|
||||
}
|
||||
|
||||
export async function createRule(doc: IPRuleDoc): Promise<IPRuleDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as IPRuleDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function deleteRule(id: string, productId: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, productId).delete();
|
||||
await collection().delete(id, productId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -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';
|
||||
|
||||
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, afterAll } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
|
||||
import { list, getByName, create, update, getDefaults } from './repository.js';
|
||||
import type { PlanConfig } from './types.js';
|
||||
|
||||
@ -39,18 +25,22 @@ const basePlan: PlanConfig = {
|
||||
|
||||
describe('plans repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetDatastoreProvider();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns plans from Cosmos', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [basePlan] });
|
||||
it('returns plans from datastore', async () => {
|
||||
await create(basePlan);
|
||||
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 () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await list('lysnrai');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].name).toBe('free');
|
||||
@ -61,13 +51,12 @@ describe('plans repository', () => {
|
||||
|
||||
describe('getByName', () => {
|
||||
it('returns plan when found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [basePlan] });
|
||||
await create(basePlan);
|
||||
const result = await getByName('pro', 'lysnrai');
|
||||
expect(result).toEqual(basePlan);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getByName('nonexistent', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -75,7 +64,6 @@ describe('plans repository', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns plan', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: basePlan });
|
||||
const result = await create(basePlan);
|
||||
expect(result).toEqual(basePlan);
|
||||
});
|
||||
@ -83,21 +71,13 @@ describe('plans repository', () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('merges updates and returns plan', async () => {
|
||||
mockRead.mockResolvedValue({ resource: basePlan });
|
||||
const updated = { ...basePlan, price: 14.99 };
|
||||
mockReplace.mockResolvedValue({ resource: updated });
|
||||
await create(basePlan);
|
||||
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 () => {
|
||||
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 });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@ -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 { DEFAULT_PLANS } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('plans');
|
||||
function collection() {
|
||||
return getCollection<PlanConfig>('plans', '/productId');
|
||||
}
|
||||
|
||||
export async function list(productId: string): Promise<PlanConfig[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<PlanConfig>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.price ASC',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
const results = await collection().findMany({
|
||||
filter: { productId },
|
||||
sort: { price: 1 },
|
||||
});
|
||||
|
||||
// If no plans in DB yet, return defaults
|
||||
if (resources.length === 0) {
|
||||
if (results.length === 0) {
|
||||
return getDefaults(productId);
|
||||
}
|
||||
return resources;
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getByName(name: string, productId: string): Promise<PlanConfig | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<PlanConfig>({
|
||||
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;
|
||||
return collection().findOne({
|
||||
filter: { productId, name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(doc: PlanConfig): Promise<PlanConfig> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as PlanConfig;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(id: string, updates: Partial<PlanConfig>): Promise<PlanConfig | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<PlanConfig>();
|
||||
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;
|
||||
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
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, afterAll } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
|
||||
import {
|
||||
listAll,
|
||||
getByReferrer,
|
||||
@ -48,40 +34,44 @@ const baseReferral: ReferralDoc = {
|
||||
|
||||
describe('referrals repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetDatastoreProvider();
|
||||
});
|
||||
|
||||
describe('listAll', () => {
|
||||
it('returns referrals', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseReferral] });
|
||||
await create(baseReferral);
|
||||
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 () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await listAll();
|
||||
const result = await listAll(100, 0, 'lysnrai');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByReferrer', () => {
|
||||
it('returns referrals for referrer', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseReferral] });
|
||||
await create(baseReferral);
|
||||
const result = await getByReferrer('user_1', 'lysnrai');
|
||||
expect(result).toEqual([baseReferral]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.referrerId).toBe('user_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByReferredEmail', () => {
|
||||
it('returns referral when found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseReferral] });
|
||||
await create(baseReferral);
|
||||
const result = await getByReferredEmail('new@example.com', 'lysnrai');
|
||||
expect(result).toEqual(baseReferral);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getByReferredEmail('none@example.com', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -89,27 +79,19 @@ describe('referrals repository', () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns referral when found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseReferral });
|
||||
await create(baseReferral);
|
||||
const result = await getById('ref_1', 'user_1');
|
||||
expect(result).toEqual(baseReferral);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
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');
|
||||
const result = await getById('nonexistent', 'user_1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns referral', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: baseReferral });
|
||||
const result = await create(baseReferral);
|
||||
expect(result).toEqual(baseReferral);
|
||||
});
|
||||
@ -117,41 +99,30 @@ describe('referrals repository', () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('merges updates and returns referral', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseReferral });
|
||||
const updated = { ...baseReferral, status: 'signed_up' as const };
|
||||
mockReplace.mockResolvedValue({ resource: updated });
|
||||
await create(baseReferral);
|
||||
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 () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
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' });
|
||||
const result = await update('nonexistent', 'user_1', { status: 'signed_up' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('countReferrals', () => {
|
||||
it('returns counts', async () => {
|
||||
mockFetchAll
|
||||
.mockResolvedValueOnce({ resources: [10] })
|
||||
.mockResolvedValueOnce({ resources: [5] })
|
||||
.mockResolvedValueOnce({ resources: [2] });
|
||||
await create(baseReferral);
|
||||
await create({ ...baseReferral, id: 'ref_2', status: 'signed_up' });
|
||||
await create({ ...baseReferral, id: 'ref_3', status: 'rewarded' });
|
||||
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 () => {
|
||||
mockFetchAll
|
||||
.mockResolvedValueOnce({ resources: [] })
|
||||
.mockResolvedValueOnce({ resources: [] })
|
||||
.mockResolvedValueOnce({ resources: [] });
|
||||
const result = await countReferrals('lysnrai');
|
||||
expect(result).toEqual({ total: 0, completed: 0, rewarded: 0 });
|
||||
});
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { ReferralDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'referrals';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<ReferralDoc>('referrals', '/referrerId');
|
||||
}
|
||||
|
||||
export async function listAll(limit = 100, offset = 0, productId?: string): Promise<ReferralDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ReferralDoc>({
|
||||
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 getByReferrer(referrerId: string, productId: string): Promise<ReferralDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ReferralDoc>({
|
||||
query:
|
||||
'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;
|
||||
return collection().findMany({
|
||||
filter: { productId, referrerId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByReferredEmail(
|
||||
email: string,
|
||||
productId: string
|
||||
): Promise<ReferralDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<ReferralDoc>({
|
||||
query:
|
||||
'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;
|
||||
return collection().findOne({
|
||||
filter: { productId, referredEmail: email.toLowerCase() },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getById(id: string, referrerId: string): Promise<ReferralDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, referrerId).read<ReferralDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, referrerId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: ReferralDoc): Promise<ReferralDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as ReferralDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -78,11 +54,7 @@ export async function update(
|
||||
updates: Partial<ReferralDoc>
|
||||
): Promise<ReferralDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, referrerId).read<ReferralDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates };
|
||||
const { resource } = await container().item(id, referrerId).replace(merged);
|
||||
return resource as ReferralDoc;
|
||||
return await collection().update(id, referrerId, updates);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -93,29 +65,14 @@ export async function countReferrals(productId: string): Promise<{
|
||||
completed: number;
|
||||
rewarded: number;
|
||||
}> {
|
||||
const { resources: totalRes } = await container()
|
||||
.items.query<number>({
|
||||
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
const { resources: completedRes } = await container()
|
||||
.items.query<number>({
|
||||
query:
|
||||
"SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status IN ('signed_up', 'subscribed', '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,
|
||||
};
|
||||
const total = await collection().count({ productId });
|
||||
const completed = await collection().count({
|
||||
productId,
|
||||
status: { $in: ['signed_up', 'subscribed', 'rewarded'] },
|
||||
});
|
||||
const rewarded = await collection().count({
|
||||
productId,
|
||||
status: 'rewarded',
|
||||
});
|
||||
return { total, completed, rewarded };
|
||||
}
|
||||
|
||||
@ -1,63 +1,43 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { SessionDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'sessions';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<SessionDoc>('sessions', '/userId');
|
||||
}
|
||||
|
||||
export async function createSession(doc: SessionDoc): Promise<SessionDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as SessionDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function getSession(id: string, userId: string): Promise<SessionDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<SessionDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUserSessions(userId: string): Promise<SessionDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<SessionDoc>(
|
||||
{
|
||||
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;
|
||||
return collection().findMany({
|
||||
filter: { userId, revokedAt: { $exists: false } },
|
||||
sort: { lastActiveAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAllUserSessions(userId: string): Promise<SessionDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<SessionDoc>(
|
||||
{
|
||||
query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@userId', value: userId }],
|
||||
},
|
||||
{ partitionKey: userId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { userId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeSession(id: string, userId: string): Promise<boolean> {
|
||||
const session = await getSession(id, userId);
|
||||
if (!session || session.revokedAt) return false;
|
||||
|
||||
await container()
|
||||
.item(id, userId)
|
||||
.replace({
|
||||
...session,
|
||||
revokedAt: new Date().toISOString(),
|
||||
});
|
||||
await collection().update(id, userId, {
|
||||
revokedAt: new Date().toISOString(),
|
||||
} as Partial<SessionDoc>);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -68,12 +48,9 @@ export async function revokeAllUserSessions(userId: string): Promise<number> {
|
||||
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await container()
|
||||
.item(session.id, userId)
|
||||
.replace({
|
||||
...session,
|
||||
revokedAt: now,
|
||||
});
|
||||
await collection().update(session.id, userId, {
|
||||
revokedAt: now,
|
||||
} as Partial<SessionDoc>);
|
||||
revoked++;
|
||||
} catch {
|
||||
// best-effort
|
||||
@ -87,12 +64,9 @@ export async function touchSession(id: string, userId: string): Promise<void> {
|
||||
const session = await getSession(id, userId);
|
||||
if (!session || session.revokedAt) return;
|
||||
|
||||
await container()
|
||||
.item(id, userId)
|
||||
.replace({
|
||||
...session,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
});
|
||||
await collection().update(id, userId, {
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
} as Partial<SessionDoc>);
|
||||
}
|
||||
|
||||
export async function isSessionRevoked(id: string, userId: string): Promise<boolean> {
|
||||
|
||||
@ -1,56 +1,36 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { NotFoundError } from '../../lib/errors.js';
|
||||
import type { IncidentDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'incidents';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
function collection() {
|
||||
return getCollection<IncidentDoc>('incidents', '/productId');
|
||||
}
|
||||
|
||||
export async function listIncidents(productId: string, limit = 20): Promise<IncidentDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<IncidentDoc>(
|
||||
{
|
||||
query:
|
||||
'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;
|
||||
return collection().findMany({
|
||||
filter: { productId },
|
||||
sort: { createdAt: -1 },
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listActiveIncidents(productId: string): Promise<IncidentDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<IncidentDoc>(
|
||||
{
|
||||
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;
|
||||
return collection().findMany({
|
||||
filter: { productId, status: { $ne: 'resolved' } },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIncident(id: string, productId: string): Promise<IncidentDoc> {
|
||||
const { resource } = await container().item(id, productId).read<IncidentDoc>();
|
||||
if (!resource) throw new NotFoundError(`Incident '${id}' not found`);
|
||||
return resource;
|
||||
const doc = await collection().findById(id, productId);
|
||||
if (!doc) throw new NotFoundError(`Incident '${id}' not found`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function createIncident(doc: IncidentDoc): Promise<IncidentDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as IncidentDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function updateIncident(doc: IncidentDoc): Promise<IncidentDoc> {
|
||||
const { resource } = await container().item(doc.id, doc.productId).replace(doc);
|
||||
return resource as IncidentDoc;
|
||||
return collection().upsert(doc);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user