Milestone 0: import zappier billing, Verae middleware, and Zapier research
Compose-ready workspace: packages/zappier (rate card, portal, Stripe), packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier (CLI app), vendor/zapier-platform, and research/zapier vendor corpus. Gate 0 structure checks pass. Product code and research are not yet wired.
This commit is contained in:
commit
b4150c8250
1364 changed files with 6814366 additions and 0 deletions
71
packages/zappier/src/db/admin-user-repo.ts
Normal file
71
packages/zappier/src/db/admin-user-repo.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { AdminUser, AdminUserRepo, makeAdminUser } from '../admin-users';
|
||||
|
||||
/** SQLite-backed admin accounts; seeds from env only when the table is empty. */
|
||||
export class SqliteAdminUserRepo implements AdminUserRepo {
|
||||
private constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
static seeded(
|
||||
db: Database.Database,
|
||||
seed: { username: string; password: string }[],
|
||||
): SqliteAdminUserRepo {
|
||||
const repo = new SqliteAdminUserRepo(db);
|
||||
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get() as { n: number };
|
||||
if (n === 0) {
|
||||
for (const s of seed) repo.save(makeAdminUser(s.username, s.password));
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
list(): AdminUser[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM admin_users ORDER BY username')
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map(toAdminUser);
|
||||
}
|
||||
|
||||
findByUsername(username: string): AdminUser | undefined {
|
||||
const r = this.db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return r ? toAdminUser(r) : undefined;
|
||||
}
|
||||
|
||||
save(user: AdminUser): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO admin_users (id, username, password_hash, active, created_ms)
|
||||
VALUES (@id, @username, @passwordHash, @active, @createdMs)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
password_hash = @passwordHash,
|
||||
active = @active`,
|
||||
)
|
||||
.run({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
passwordHash: user.passwordHash,
|
||||
active: user.active ? 1 : 0,
|
||||
createdMs: user.createdMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
return {
|
||||
id: r.id as string,
|
||||
username: r.username as string,
|
||||
passwordHash: r.password_hash as string,
|
||||
active: (r.active as number) === 1,
|
||||
createdMs: r.created_ms as number,
|
||||
};
|
||||
}
|
||||
68
packages/zappier/src/db/billing-repo.ts
Normal file
68
packages/zappier/src/db/billing-repo.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface BillingReportRepo {
|
||||
getReportedCents(customerId: string, period: string): number;
|
||||
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void;
|
||||
}
|
||||
|
||||
export interface JobLockRepo {
|
||||
tryAcquireLock(name: string, ttlMs: number): boolean;
|
||||
releaseLock(name: string): void;
|
||||
}
|
||||
|
||||
export class SqliteBillingReportRepo implements BillingReportRepo, JobLockRepo {
|
||||
constructor(
|
||||
private db: Database.Database,
|
||||
private now: () => number = Date.now,
|
||||
) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS billing_reports (
|
||||
customer_id TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
reported_cents INTEGER NOT NULL,
|
||||
reported_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (customer_id, period)
|
||||
)
|
||||
`);
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS job_locks (
|
||||
name TEXT PRIMARY KEY,
|
||||
acquired_at_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
getReportedCents(customerId: string, period: string): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT reported_cents FROM billing_reports WHERE customer_id = ? AND period = ?')
|
||||
.get(customerId, period) as { reported_cents: number } | undefined;
|
||||
return row?.reported_cents ?? 0;
|
||||
}
|
||||
|
||||
upsertReportedCents(customerId: string, period: string, cumulativeCents: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO billing_reports (customer_id, period, reported_cents, reported_at_ms)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(customer_id, period) DO UPDATE SET
|
||||
reported_cents = excluded.reported_cents,
|
||||
reported_at_ms = excluded.reported_at_ms`,
|
||||
)
|
||||
.run(customerId, period, cumulativeCents, this.now());
|
||||
}
|
||||
|
||||
tryAcquireLock(name: string, ttlMs: number): boolean {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO job_locks (name, acquired_at_ms) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET acquired_at_ms = excluded.acquired_at_ms
|
||||
WHERE job_locks.acquired_at_ms <= excluded.acquired_at_ms - ?`,
|
||||
)
|
||||
.run(name, this.now(), ttlMs);
|
||||
return result.changes === 1;
|
||||
}
|
||||
|
||||
releaseLock(name: string): void {
|
||||
this.db.prepare('DELETE FROM job_locks WHERE name = ?').run(name);
|
||||
}
|
||||
}
|
||||
112
packages/zappier/src/db/customer-repo.ts
Normal file
112
packages/zappier/src/db/customer-repo.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { Customer, CustomerRepo } from '../auth';
|
||||
|
||||
export class SqliteCustomerRepo implements CustomerRepo {
|
||||
constructor(private db: Database.Database, seed: Customer[] = []) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
tier_id TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL UNIQUE,
|
||||
stripe_customer_id TEXT,
|
||||
multiplier_override REAL
|
||||
)
|
||||
`);
|
||||
// Idempotent migrations for pre-existing databases.
|
||||
this.ensureColumn('billing_type', `billing_type TEXT NOT NULL DEFAULT 'stripe'`);
|
||||
this.ensureColumn('email', 'email TEXT');
|
||||
this.ensureColumn('password_hash', 'password_hash TEXT');
|
||||
this.ensureColumn('totp_secret', 'totp_secret TEXT');
|
||||
this.ensureColumn('totp_enabled', 'totp_enabled INTEGER NOT NULL DEFAULT 0');
|
||||
this.ensureColumn('balance_cents', 'balance_cents INTEGER NOT NULL DEFAULT 0');
|
||||
this.ensureColumn('email_invoicing', 'email_invoicing INTEGER NOT NULL DEFAULT 0');
|
||||
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number };
|
||||
if (n === 0) {
|
||||
for (const c of seed) this.save(c);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureColumn(name: string, ddl: string): void {
|
||||
const cols = this.db.prepare('PRAGMA table_info(customers)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === name)) {
|
||||
this.db.exec(`ALTER TABLE customers ADD COLUMN ${ddl}`);
|
||||
}
|
||||
}
|
||||
|
||||
findByApiKey(apiKey: string): Customer | undefined {
|
||||
const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return r ? toCustomer(r) : undefined;
|
||||
}
|
||||
|
||||
findByEmail(email: string): Customer | undefined {
|
||||
const r = this.db
|
||||
.prepare('SELECT * FROM customers WHERE lower(email) = lower(?)')
|
||||
.get(email) as Record<string, unknown> | undefined;
|
||||
return r ? toCustomer(r) : undefined;
|
||||
}
|
||||
|
||||
list(): Customer[] {
|
||||
const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return rows.map(toCustomer);
|
||||
}
|
||||
|
||||
save(customer: Customer): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override, billing_type, email, password_hash, totp_secret, totp_enabled, balance_cents, email_invoicing)
|
||||
VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride, @billingType, @email, @passwordHash, @totpSecret, @totpEnabled, @balanceCents, @emailInvoicing)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = @name,
|
||||
tier_id = @tierId,
|
||||
api_key = @apiKey,
|
||||
stripe_customer_id = @stripeCustomerId,
|
||||
multiplier_override = @multiplierOverride,
|
||||
billing_type = @billingType,
|
||||
email = @email,
|
||||
password_hash = @passwordHash,
|
||||
totp_secret = @totpSecret,
|
||||
totp_enabled = @totpEnabled,
|
||||
balance_cents = @balanceCents,
|
||||
email_invoicing = @emailInvoicing`,
|
||||
)
|
||||
.run({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
tierId: customer.tierId,
|
||||
apiKey: customer.apiKey,
|
||||
stripeCustomerId: customer.stripeCustomerId ?? null,
|
||||
multiplierOverride: customer.multiplierOverride ?? null,
|
||||
billingType: customer.billingType ?? 'stripe',
|
||||
email: customer.email ?? null,
|
||||
passwordHash: customer.passwordHash ?? null,
|
||||
totpSecret: customer.totpSecret ?? null,
|
||||
totpEnabled: customer.totpEnabled ? 1 : 0,
|
||||
balanceCents: customer.balanceCents ?? 0,
|
||||
emailInvoicing: customer.emailInvoicing ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toCustomer(r: Record<string, unknown>): Customer {
|
||||
return {
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
tierId: r.tier_id as string,
|
||||
apiKey: r.api_key as string,
|
||||
stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined,
|
||||
multiplierOverride: (r.multiplier_override as number | null) ?? undefined,
|
||||
billingType: ((r.billing_type as string | null) ?? 'stripe') as Customer['billingType'],
|
||||
email: (r.email as string | null) ?? undefined,
|
||||
passwordHash: (r.password_hash as string | null) ?? undefined,
|
||||
totpSecret: (r.totp_secret as string | null) ?? undefined,
|
||||
totpEnabled: ((r.totp_enabled as number | null) ?? 0) === 1,
|
||||
balanceCents: (r.balance_cents as number | null) ?? 0,
|
||||
emailInvoicing: ((r.email_invoicing as number | null) ?? 0) === 1,
|
||||
};
|
||||
}
|
||||
119
packages/zappier/src/db/invoice-repo.ts
Normal file
119
packages/zappier/src/db/invoice-repo.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { Invoice, InvoiceLine, InvoiceRepo } from '../invoicing';
|
||||
|
||||
export class SqliteInvoiceRepo implements InvoiceRepo {
|
||||
constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
total_cents INTEGER NOT NULL,
|
||||
credit_cents INTEGER NOT NULL,
|
||||
billable_cents INTEGER NOT NULL,
|
||||
billing_type TEXT NOT NULL DEFAULT 'stripe',
|
||||
po_number TEXT,
|
||||
issued_at_ms INTEGER,
|
||||
due_at_ms INTEGER,
|
||||
paid_at_ms INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||
invoice_id TEXT NOT NULL,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
calls INTEGER NOT NULL,
|
||||
cents INTEGER NOT NULL,
|
||||
PRIMARY KEY (invoice_id, endpoint_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
save(invoice: Invoice): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO invoices
|
||||
(id, customer_id, period, status, total_cents, credit_cents, billable_cents,
|
||||
billing_type, po_number, issued_at_ms, due_at_ms, paid_at_ms)
|
||||
VALUES (@id, @customerId, @period, @status, @totalCents, @creditCents, @billableCents,
|
||||
@billingType, @poNumber, @issuedAtMs, @dueAtMs, @paidAtMs)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
customer_id = @customerId, period = @period, status = @status,
|
||||
total_cents = @totalCents, credit_cents = @creditCents,
|
||||
billable_cents = @billableCents, billing_type = @billingType,
|
||||
po_number = @poNumber, issued_at_ms = @issuedAtMs,
|
||||
due_at_ms = @dueAtMs, paid_at_ms = @paidAtMs`,
|
||||
)
|
||||
.run({
|
||||
...invoice,
|
||||
poNumber: invoice.poNumber ?? null,
|
||||
issuedAtMs: invoice.issuedAtMs ?? null,
|
||||
dueAtMs: invoice.dueAtMs ?? null,
|
||||
paidAtMs: invoice.paidAtMs ?? null,
|
||||
});
|
||||
// Replace the line set atomically with the invoice row.
|
||||
this.db.prepare('DELETE FROM invoice_lines WHERE invoice_id = ?').run(invoice.id);
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO invoice_lines (invoice_id, endpoint_id, calls, cents) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
for (const line of invoice.lines) {
|
||||
insert.run(invoice.id, line.endpointId, line.calls, line.cents);
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Invoice | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM invoices WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? this.toInvoice(row) : undefined;
|
||||
}
|
||||
|
||||
list(filter: { customerId?: string; period?: string; status?: Invoice['status'] }): Invoice[] {
|
||||
const where: string[] = [];
|
||||
const params: Record<string, string> = {};
|
||||
if (filter.customerId) {
|
||||
where.push('customer_id = @customerId');
|
||||
params.customerId = filter.customerId;
|
||||
}
|
||||
if (filter.period) {
|
||||
where.push('period = @period');
|
||||
params.period = filter.period;
|
||||
}
|
||||
if (filter.status) {
|
||||
where.push('status = @status');
|
||||
params.status = filter.status;
|
||||
}
|
||||
const sql = `SELECT * FROM invoices ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`;
|
||||
const rows = this.db.prepare(sql).all(params) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.toInvoice(r));
|
||||
}
|
||||
|
||||
nextSequence(period: string): number {
|
||||
const { n } = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM invoices WHERE period = ?')
|
||||
.get(period) as { n: number };
|
||||
return n + 1;
|
||||
}
|
||||
|
||||
private toInvoice(row: Record<string, unknown>): Invoice {
|
||||
const lines = this.db
|
||||
.prepare('SELECT endpoint_id, calls, cents FROM invoice_lines WHERE invoice_id = ? ORDER BY endpoint_id')
|
||||
.all(row.id as string) as { endpoint_id: string; calls: number; cents: number }[];
|
||||
return {
|
||||
id: row.id as string,
|
||||
customerId: row.customer_id as string,
|
||||
period: row.period as string,
|
||||
status: row.status as Invoice['status'],
|
||||
lines: lines.map(
|
||||
(l): InvoiceLine => ({ endpointId: l.endpoint_id, calls: l.calls, cents: l.cents }),
|
||||
),
|
||||
totalCents: row.total_cents as number,
|
||||
creditCents: row.credit_cents as number,
|
||||
billableCents: row.billable_cents as number,
|
||||
billingType: row.billing_type as Invoice['billingType'],
|
||||
poNumber: (row.po_number as string | null) ?? undefined,
|
||||
issuedAtMs: (row.issued_at_ms as number | null) ?? undefined,
|
||||
dueAtMs: (row.due_at_ms as number | null) ?? undefined,
|
||||
paidAtMs: (row.paid_at_ms as number | null) ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
106
packages/zappier/src/db/pricing-store.ts
Normal file
106
packages/zappier/src/db/pricing-store.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
DEFAULT_RATE_CARD,
|
||||
DEFAULT_TIERS,
|
||||
PriceRule,
|
||||
PricingStore,
|
||||
RateCard,
|
||||
TierConfig,
|
||||
} from '../pricing';
|
||||
|
||||
export class SqlitePricingStore implements PricingStore {
|
||||
constructor(
|
||||
private db: Database.Database,
|
||||
seedCard: RateCard = DEFAULT_RATE_CARD,
|
||||
seedTiers: TierConfig[] = DEFAULT_TIERS,
|
||||
) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS price_endpoints (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
rule_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tiers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
multiplier REAL NOT NULL,
|
||||
monthly_credit_cents INTEGER NOT NULL,
|
||||
default_rule_json TEXT
|
||||
)
|
||||
`);
|
||||
const { n: endpointCount } = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM price_endpoints')
|
||||
.get() as { n: number };
|
||||
if (endpointCount === 0) {
|
||||
for (const [id, rule] of Object.entries(seedCard.endpoints)) {
|
||||
this.upsertEndpoint(id, rule);
|
||||
}
|
||||
}
|
||||
const { n: tierCount } = this.db.prepare('SELECT COUNT(*) AS n FROM tiers').get() as {
|
||||
n: number;
|
||||
};
|
||||
if (tierCount === 0) {
|
||||
for (const tier of seedTiers) {
|
||||
this.upsertTier(tier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRateCard(): RateCard {
|
||||
const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record<string, unknown>[];
|
||||
const endpoints: Record<string, PriceRule> = {};
|
||||
for (const r of rows) {
|
||||
endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule;
|
||||
}
|
||||
return { endpoints };
|
||||
}
|
||||
|
||||
getTiers(): TierConfig[] {
|
||||
const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
multiplier: r.multiplier as number,
|
||||
monthlyCreditCents: r.monthly_credit_cents as number,
|
||||
defaultRule: r.default_rule_json
|
||||
? (JSON.parse(r.default_rule_json as string) as PriceRule)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
upsertEndpoint(endpointId: string, rule: PriceRule): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?)
|
||||
ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`,
|
||||
)
|
||||
.run(endpointId, JSON.stringify(rule));
|
||||
}
|
||||
|
||||
deleteEndpoint(endpointId: string): void {
|
||||
this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId);
|
||||
}
|
||||
|
||||
upsertTier(tier: TierConfig): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json)
|
||||
VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = @name,
|
||||
multiplier = @multiplier,
|
||||
monthly_credit_cents = @monthlyCreditCents,
|
||||
default_rule_json = @defaultRuleJson`,
|
||||
)
|
||||
.run({
|
||||
id: tier.id,
|
||||
name: tier.name,
|
||||
multiplier: tier.multiplier,
|
||||
monthlyCreditCents: tier.monthlyCreditCents,
|
||||
defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null,
|
||||
});
|
||||
}
|
||||
|
||||
deleteTier(tierId: string): void {
|
||||
this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId);
|
||||
}
|
||||
}
|
||||
51
packages/zappier/src/db/session-repo.ts
Normal file
51
packages/zappier/src/db/session-repo.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { newSessionToken, PortalSession, SessionRepo } from '../accounts';
|
||||
|
||||
/** SQLite-backed portal sessions; survives server restarts. */
|
||||
export class SqliteSessionRepo implements SessionRepo {
|
||||
constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS portal_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
customer_id TEXT NOT NULL,
|
||||
created_ms INTEGER NOT NULL,
|
||||
expires_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
create(customerId: string, ttlMs: number): PortalSession {
|
||||
const now = Date.now();
|
||||
const session: PortalSession = {
|
||||
token: newSessionToken(),
|
||||
customerId,
|
||||
createdMs: now,
|
||||
expiresMs: now + ttlMs,
|
||||
};
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO portal_sessions (token, customer_id, created_ms, expires_ms) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.run(session.token, session.customerId, session.createdMs, session.expiresMs);
|
||||
return session;
|
||||
}
|
||||
|
||||
get(token: string, nowMs = Date.now()): PortalSession | undefined {
|
||||
const r = this.db
|
||||
.prepare('SELECT * FROM portal_sessions WHERE token = ?')
|
||||
.get(token) as
|
||||
| { token: string; customer_id: string; created_ms: number; expires_ms: number }
|
||||
| undefined;
|
||||
if (!r || r.expires_ms <= nowMs) return undefined;
|
||||
return {
|
||||
token: r.token,
|
||||
customerId: r.customer_id,
|
||||
createdMs: r.created_ms,
|
||||
expiresMs: r.expires_ms,
|
||||
};
|
||||
}
|
||||
|
||||
delete(token: string): void {
|
||||
this.db.prepare('DELETE FROM portal_sessions WHERE token = ?').run(token);
|
||||
}
|
||||
}
|
||||
61
packages/zappier/src/db/usage-repo.ts
Normal file
61
packages/zappier/src/db/usage-repo.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage';
|
||||
|
||||
export class SqliteUsageRepo implements UsageRepo {
|
||||
constructor(private db: Database.Database) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS usage_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
cents INTEGER NOT NULL,
|
||||
metadata_bytes INTEGER NOT NULL,
|
||||
attachment_bytes INTEGER NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
record(entry: UsageEntry): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO usage_entries
|
||||
(customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
entry.customerId,
|
||||
entry.endpointId,
|
||||
entry.cents,
|
||||
entry.metadataBytes,
|
||||
entry.attachmentBytes,
|
||||
entry.timestamp.getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
listFor(customerId: string, since?: Date): UsageEntry[] {
|
||||
const rows = (
|
||||
since
|
||||
? this.db
|
||||
.prepare(
|
||||
'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms',
|
||||
)
|
||||
.all(customerId, since.getTime())
|
||||
: this.db
|
||||
.prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms')
|
||||
.all(customerId)
|
||||
) as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
customerId: r.customer_id as string,
|
||||
endpointId: r.endpoint_id as string,
|
||||
cents: r.cents as number,
|
||||
metadataBytes: r.metadata_bytes as number,
|
||||
attachmentBytes: r.attachment_bytes as number,
|
||||
timestamp: new Date(r.timestamp_ms as number),
|
||||
}));
|
||||
}
|
||||
|
||||
summaryFor(customerId: string, since?: Date): UsageSummary {
|
||||
return summarize(customerId, this.listFor(customerId, since));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue