Unverified 提交 5f823117 authored 作者: Ryan Groch's avatar Ryan Groch 提交者: GitHub

test: adds search-replace evaluation suite (#3205)

See `src/__tests__/evals/README.md` for usage. Other notes: - The test fixtures are 300+ lines each. Even so, I still think some of them are a little too easy. I might swap some of them out for more challenging ones, or edit them so that they're not so straightforward. - This currently still only tests `search_replace`, so I don't yet have a way to compare correctness/token usage/time taken of `search_replace` vs `edit_file` vs `write_file`. - Otherwise, though, I think I'm fairly thorough about collecting data. One thing I'm missing is the cost (it would probably be a rough estimate at best) but I'm at least able to store the number of input/output tokens for each tool call. <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3205" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 9dbc0630
......@@ -113,3 +113,6 @@ __pycache__/
# Storybook
storybook-static/
# Eval framework — run results
eval-results/
......@@ -35,6 +35,7 @@
"fmt:check": "npx oxfmt --check",
"fmt": "npx oxfmt",
"presubmit": "npm run fmt:check && npm run lint",
"eval": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config vitest.eval.config.ts",
"test": "cross-env NODE_OPTIONS=--no-deprecation VITE_CJS_IGNORE_WARNING=true vitest run",
"test:watch": "cross-env NODE_OPTIONS=--no-deprecation VITE_CJS_IGNORE_WARNING=true vitest",
"test:ui": "cross-env NODE_OPTIONS=--no-deprecation VITE_CJS_IGNORE_WARNING=true vitest --ui",
......
差异被折叠。
// UserProfile.tsx — class-based user profile component
import React from "react";
import { fetchUser, updateUser, fetchUserActivity } from "../services/userService";
import type { User, ActivityEntry } from "../types";
interface Props {
userId: string;
onProfileUpdated?: (user: User) => void;
readOnly?: boolean;
}
interface State {
user: User | null;
loading: boolean;
editing: boolean;
draft: Partial<User>;
error: string | null;
saveError: string | null;
saving: boolean;
activity: ActivityEntry[];
activityLoading: boolean;
activityError: string | null;
showActivity: boolean;
uploadingAvatar: boolean;
avatarError: string | null;
}
export class UserProfile extends React.Component<Props, State> {
private avatarInputRef = React.createRef<HTMLInputElement>();
constructor(props: Props) {
super(props);
this.state = {
user: null,
loading: true,
editing: false,
draft: {},
error: null,
saveError: null,
saving: false,
activity: [],
activityLoading: false,
activityError: null,
showActivity: false,
uploadingAvatar: false,
avatarError: null,
};
}
async componentDidMount() {
await this.loadUser();
}
async componentDidUpdate(prevProps: Props) {
if (prevProps.userId !== this.props.userId) {
this.setState({
editing: false,
draft: {},
saveError: null,
activity: [],
showActivity: false,
});
await this.loadUser();
}
}
componentWillUnmount() {
// Clean up any pending state updates
}
async loadUser() {
this.setState({ loading: true, error: null });
try {
const user = await fetchUser(this.props.userId);
this.setState({ user, loading: false });
} catch (err) {
this.setState({
error: err instanceof Error ? err.message : "Failed to load user",
loading: false,
});
}
}
async loadActivity() {
this.setState({ activityLoading: true, activityError: null });
try {
const activity = await fetchUserActivity(this.props.userId);
this.setState({ activity, activityLoading: false });
} catch (err) {
this.setState({
activityError: err instanceof Error ? err.message : "Failed to load activity",
activityLoading: false,
});
}
}
handleEdit = () => {
this.setState({ editing: true, draft: { ...this.state.user }, saveError: null });
};
handleCancel = () => {
this.setState({ editing: false, draft: {}, saveError: null });
};
handleChange = (field: keyof User, value: string) => {
this.setState((prev) => ({ draft: { ...prev.draft, [field]: value } }));
};
handleSave = async () => {
this.setState({ saving: true, saveError: null });
try {
const updated = await updateUser(this.props.userId, this.state.draft);
this.setState({ user: updated, editing: false, draft: {}, saving: false });
this.props.onProfileUpdated?.(updated);
} catch (err) {
this.setState({
saveError: err instanceof Error ? err.message : "Failed to save changes",
saving: false,
});
}
};
handleToggleActivity = async () => {
const { showActivity, activity } = this.state;
if (!showActivity && activity.length === 0) {
await this.loadActivity();
}
this.setState((prev) => ({ showActivity: !prev.showActivity }));
};
handleAvatarClick = () => {
this.avatarInputRef.current?.click();
};
handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
this.setState({ avatarError: "Avatar must be under 5 MB" });
return;
}
this.setState({ uploadingAvatar: true, avatarError: null });
try {
// Upload stub — real impl would POST to /api/avatars
await new Promise((r) => setTimeout(r, 500));
const fakeUrl = URL.createObjectURL(file);
const updated = await updateUser(this.props.userId, { avatarUrl: fakeUrl });
this.setState({ user: updated, uploadingAvatar: false });
} catch (err) {
this.setState({
avatarError: err instanceof Error ? err.message : "Failed to upload avatar",
uploadingAvatar: false,
});
}
};
renderActivityFeed() {
const { activity, activityLoading, activityError } = this.state;
if (activityLoading) {
return <p className="activity-loading">Loading activity…</p>;
}
if (activityError) {
return <p className="activity-error">{activityError}</p>;
}
if (activity.length === 0) {
return <p className="activity-empty">No recent activity.</p>;
}
return (
<ul className="activity-list">
{activity.map((entry) => (
<li key={entry.id} className="activity-entry">
<span className="activity-action">{entry.action}</span>
<span className="activity-time">
{new Date(entry.timestamp).toLocaleString()}
</span>
</li>
))}
</ul>
);
}
render() {
const {
user,
loading,
editing,
draft,
error,
saveError,
saving,
showActivity,
uploadingAvatar,
avatarError,
} = this.state;
const { readOnly } = this.props;
if (loading) {
return <div className="profile-loading">Loading profile…</div>;
}
if (error) {
return (
<div className="profile-error">
<p>{error}</p>
<button onClick={() => this.loadUser()}>Retry</button>
</div>
);
}
if (!user) return null;
return (
<div className="user-profile">
<div className="profile-header">
<div className="avatar-wrapper" onClick={!readOnly ? this.handleAvatarClick : undefined}>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={`${user.name}'s avatar`} className="avatar" />
) : (
<div className="avatar-placeholder">{user.name.charAt(0).toUpperCase()}</div>
)}
{!readOnly && (
<div className="avatar-overlay">{uploadingAvatar ? "Uploading…" : "Change"}</div>
)}
</div>
{!readOnly && (
<input
ref={this.avatarInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={this.handleAvatarChange}
/>
)}
{avatarError && <p className="avatar-error">{avatarError}</p>}
<h1>{user.name}</h1>
<span className={`role-badge role-badge--${user.role}`}>{user.role}</span>
</div>
{editing ? (
<form
className="profile-form"
onSubmit={(e) => {
e.preventDefault();
this.handleSave();
}}
>
<label>
Name
<input
value={draft.name ?? ""}
onChange={(e) => this.handleChange("name", e.target.value)}
/>
</label>
<label>
Email
<input
type="email"
value={draft.email ?? ""}
onChange={(e) => this.handleChange("email", e.target.value)}
/>
</label>
<label>
Bio
<textarea
value={draft.bio ?? ""}
rows={4}
onChange={(e) => this.handleChange("bio", e.target.value)}
/>
</label>
{saveError && <p className="error">{saveError}</p>}
<div className="form-actions">
<button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
<button type="button" onClick={this.handleCancel} disabled={saving}>
Cancel
</button>
</div>
</form>
) : (
<div className="profile-view">
<p>
<strong>Email:</strong> {user.email}
</p>
<p>
<strong>Role:</strong> {user.role}
</p>
{user.bio && (
<p>
<strong>Bio:</strong> {user.bio}
</p>
)}
<p>
<strong>Member since:</strong>{" "}
{new Date(user.createdAt).toLocaleDateString()}
</p>
{!readOnly && (
<button onClick={this.handleEdit}>Edit Profile</button>
)}
</div>
)}
<div className="activity-section">
<button className="toggle-activity" onClick={this.handleToggleActivity}>
{showActivity ? "Hide activity" : "Show recent activity"}
</button>
{showActivity && this.renderActivityFeed()}
</div>
</div>
);
}
}
// Analytics event tracking.
//
// Historical note: earlier versions used `console.log` directly, which
// made output noisy in the browser's console. We now route through a
// logger abstraction instead.
interface Event {
name: string;
props: Record<string, unknown>;
timestamp: number;
sessionId: string;
userId?: string;
}
interface Session {
id: string;
startedAt: number;
userId?: string;
userAgent: string;
referrer: string;
}
interface FlushResult {
sent: number;
failed: number;
requeued: number;
}
type ConsentLevel = "none" | "essential" | "analytics" | "full";
let currentSession: Session | null = null;
let consentLevel: ConsentLevel = "none";
const queue: Event[] = [];
const MAX_BATCH_SIZE = 500;
const FLUSH_INTERVAL_MS = 30_000;
let flushTimer: ReturnType<typeof setInterval> | null = null;
// ── Session management ─────────────────────────────────────────────────────
export function startSession(userId?: string): Session {
const session: Session = {
id: Math.random().toString(36).slice(2),
startedAt: Date.now(),
userId,
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "",
referrer: typeof document !== "undefined" ? document.referrer : "",
};
currentSession = session;
console.log(`analytics session started: ${session.id}`);
return session;
}
export function endSession(): void {
if (!currentSession) {
console.warn("endSession called with no active session");
return;
}
const durationMs = Date.now() - currentSession.startedAt;
console.log(`analytics session ended: ${currentSession.id} (${durationMs}ms)`);
currentSession = null;
}
export function setConsent(level: ConsentLevel): void {
console.log(`analytics consent changed: ${consentLevel}${level}`);
consentLevel = level;
if (level === "none") {
queue.splice(0, queue.length);
console.log("analytics queue cleared due to consent withdrawal");
}
}
// ── Event tracking ─────────────────────────────────────────────────────────
export function track(name: string, props: Record<string, unknown> = {}): void {
if (!name) {
console.warn("track called with empty event name");
return;
}
if (consentLevel === "none") {
console.warn(`track("${name}") skipped — no analytics consent`);
return;
}
const event: Event = {
name,
props,
timestamp: Date.now(),
sessionId: currentSession?.id ?? "no-session",
userId: currentSession?.userId,
};
queue.push(event);
console.log(`tracked event: ${name}`);
if (queue.length >= MAX_BATCH_SIZE) {
console.warn(`analytics queue full (${queue.length}), flushing immediately`);
flush();
}
}
export function trackPageView(path: string, title: string): void {
console.log(`page view: ${path}`);
track("page_view", { path, title });
}
export function trackError(err: Error, context: Record<string, unknown> = {}): void {
console.error(`analytics error event: ${err.message}`, err);
track("error", {
message: err.message,
stack: err.stack ?? null,
...context,
});
}
export function trackClick(elementId: string, label: string): void {
console.log(`click: ${elementId}${label}`);
track("click", { elementId, label });
}
export function trackFormSubmit(formId: string, fieldCount: number): void {
if (fieldCount === 0) {
console.warn(`trackFormSubmit("${formId}") called with no fields`);
}
track("form_submit", { formId, fieldCount });
}
export function trackSearch(query: string, resultCount: number): void {
if (!query.trim()) {
console.warn("trackSearch called with empty query");
return;
}
console.log(`search: "${query}" — ${resultCount} results`);
track("search", { query, resultCount });
}
export function trackTiming(category: string, variable: string, durationMs: number): void {
if (durationMs < 0) {
console.error(`trackTiming: negative duration ${durationMs}ms for ${category}/${variable}`);
return;
}
track("timing", { category, variable, durationMs });
}
// ── Flush ──────────────────────────────────────────────────────────────────
export function flush(): FlushResult {
if (queue.length === 0) {
console.log("flush: nothing to send");
return { sent: 0, failed: 0, requeued: 0 };
}
const drained = queue.splice(0, queue.length);
console.log(`flushing ${drained.length} events`);
try {
sendToBackend(drained);
} catch (err) {
console.error("flush failed, re-queueing events", err);
queue.unshift(...drained);
throw err;
}
return { sent: drained.length, failed: 0, requeued: 0 };
}
export function startAutoFlush(): void {
if (flushTimer !== null) {
console.warn("startAutoFlush called while timer already running");
return;
}
flushTimer = setInterval(() => {
console.log("auto-flush triggered");
flush();
}, FLUSH_INTERVAL_MS);
console.log(`auto-flush scheduled every ${FLUSH_INTERVAL_MS}ms`);
}
export function stopAutoFlush(): void {
if (flushTimer === null) {
console.warn("stopAutoFlush called with no active timer");
return;
}
clearInterval(flushTimer);
flushTimer = null;
console.log("auto-flush stopped");
}
export function queueSize(): number {
return queue.length;
}
// ── Backend transport ──────────────────────────────────────────────────────
function sendToBackend(events: Event[]): void {
// The help text below mentions "console" — do not touch it.
const helpText =
"Events are buffered. Run `flush()` to send. Check the console for errors.";
if (events.length > 1000) {
console.warn(`sending large batch of ${events.length} events`);
}
// XHR omitted for fixture brevity.
void helpText;
}
// ── User identification ────────────────────────────────────────────────────
let _identifiedUserId: string | null = null;
export function identify(userId: string, traits: Record<string, unknown> = {}): void {
if (!userId) {
console.warn("identify called with empty userId");
return;
}
_identifiedUserId = userId;
if (currentSession) {
currentSession.userId = userId;
}
console.log(`analytics identify: ${userId}`);
track("identify", { userId, ...traits });
}
export function reset(): void {
if (!_identifiedUserId) {
console.warn("analytics reset called with no identified user");
}
_identifiedUserId = null;
endSession();
console.log("analytics reset");
}
// ── E-commerce events ──────────────────────────────────────────────────────
export function trackProductViewed(
productId: string,
name: string,
price: number,
category: string,
): void {
console.log(`product viewed: ${productId} (${name})`);
track("product_viewed", { productId, name, price, category });
}
export function trackAddToCart(
productId: string,
quantity: number,
price: number,
): void {
if (quantity <= 0) {
console.error(`trackAddToCart: invalid quantity ${quantity} for product ${productId}`);
return;
}
track("add_to_cart", { productId, quantity, price });
}
export function trackCheckoutStarted(cartValue: number, itemCount: number): void {
if (cartValue < 0) {
console.error(`trackCheckoutStarted: negative cart value ${cartValue}`);
return;
}
console.log(`checkout started — ${itemCount} items, $${cartValue.toFixed(2)}`);
track("checkout_started", { cartValue, itemCount });
}
export function trackOrderCompleted(
orderId: string,
revenue: number,
currency: string,
): void {
console.log(`order completed: ${orderId}${currency} ${revenue.toFixed(2)}`);
track("order_completed", { orderId, revenue, currency });
}
export function trackOrderCancelled(orderId: string, reason: string): void {
console.warn(`order cancelled: ${orderId}${reason}`);
track("order_cancelled", { orderId, reason });
}
// ── Feature flags ──────────────────────────────────────────────────────────
const flagOverrides: Record<string, boolean> = {};
export function setFlagOverride(flag: string, value: boolean): void {
console.log(`feature flag override: ${flag} = ${value}`);
flagOverrides[flag] = value;
}
export function clearFlagOverride(flag: string): void {
if (!(flag in flagOverrides)) {
console.warn(`clearFlagOverride: no override found for flag "${flag}"`);
return;
}
delete flagOverrides[flag];
console.log(`feature flag override cleared: ${flag}`);
}
export function trackFeatureFlagEvaluated(
flag: string,
value: boolean,
reason: string,
): void {
track("feature_flag_evaluated", { flag, value, reason });
}
export function trackExperimentExposed(
experimentId: string,
variant: string,
userId?: string,
): void {
if (!experimentId) {
console.warn("trackExperimentExposed called with empty experimentId");
return;
}
console.log(`experiment exposure: ${experimentId} variant=${variant}`);
track("experiment_exposed", { experimentId, variant, userId });
}
// cache_manager.ts — in-memory LRU cache with TTL and size limits
interface CacheEntry<T> {
value: T;
expiresAt: number;
sizeBytes: number;
lastAccessedAt: number;
accessCount: number;
key: string;
}
interface CacheStats {
entries: number;
totalBytes: number;
hits: number;
misses: number;
evictions: number;
expirations: number;
}
interface WarmingSpec<T> {
key: string;
fetch: () => Promise<T>;
sizeEstimate: number;
}
const entries = new Map<string, CacheEntry<unknown>>();
let totalBytes = 0;
let hits = 0;
let misses = 0;
let evictions = 0;
let expirations = 0;
// ── Write ──────────────────────────────────────────────────────────────────
export function set<T>(key: string, value: T, sizeBytes: number): void {
if (sizeBytes > 10 * 1024 * 1024) {
throw new Error(`entry too large: ${sizeBytes} bytes`);
}
while (totalBytes + sizeBytes > 100 * 1024 * 1024) {
evictOldest();
}
entries.set(key, {
value,
expiresAt: Date.now() + 60 * 60 * 1000,
sizeBytes,
lastAccessedAt: Date.now(),
accessCount: 0,
key,
});
totalBytes += sizeBytes;
}
export function setWithTtl<T>(
key: string,
value: T,
sizeBytes: number,
ttlMs: number,
): void {
if (ttlMs <= 0) {
throw new Error(`ttlMs must be positive, got ${ttlMs}`);
}
if (sizeBytes > 10 * 1024 * 1024) {
throw new Error(`entry too large: ${sizeBytes} bytes`);
}
while (totalBytes + sizeBytes > 100 * 1024 * 1024) {
evictOldest();
}
entries.set(key, {
value,
expiresAt: Date.now() + ttlMs,
sizeBytes,
lastAccessedAt: Date.now(),
accessCount: 0,
key,
});
totalBytes += sizeBytes;
}
export function setMany<T>(
items: Array<{ key: string; value: T; sizeBytes: number }>,
): void {
for (const item of items) {
set(item.key, item.value, item.sizeBytes);
}
}
// ── Read ───────────────────────────────────────────────────────────────────
export function get<T>(key: string): T | null {
const entry = entries.get(key);
if (!entry) {
misses++;
return null;
}
if (entry.expiresAt < Date.now()) {
entries.delete(key);
totalBytes -= entry.sizeBytes;
expirations++;
misses++;
return null;
}
entry.lastAccessedAt = Date.now();
entry.accessCount++;
hits++;
return entry.value as T;
}
export function getOrSet<T>(
key: string,
factory: () => T,
sizeBytes: number,
): T {
const cached = get<T>(key);
if (cached !== null) return cached;
const value = factory();
set(key, value, sizeBytes);
return value;
}
export function peek<T>(key: string): T | null {
const entry = entries.get(key);
if (!entry || entry.expiresAt < Date.now()) return null;
return entry.value as T;
}
export function has(key: string): boolean {
const entry = entries.get(key);
if (!entry) return false;
if (entry.expiresAt < Date.now()) {
entries.delete(key);
totalBytes -= entry.sizeBytes;
expirations++;
return false;
}
return true;
}
export function ttlRemainingMs(key: string): number | null {
const entry = entries.get(key);
if (!entry) return null;
const remaining = entry.expiresAt - Date.now();
return remaining > 0 ? remaining : null;
}
// ── Delete ─────────────────────────────────────────────────────────────────
export function del(key: string): boolean {
const entry = entries.get(key);
if (!entry) return false;
totalBytes -= entry.sizeBytes;
entries.delete(key);
return true;
}
export function delMany(keys: string[]): number {
let removed = 0;
for (const key of keys) {
if (del(key)) removed++;
}
return removed;
}
export function clear(): void {
entries.clear();
totalBytes = 0;
}
// ── Maintenance ────────────────────────────────────────────────────────────
export function pruneExpired(): number {
const now = Date.now();
let removed = 0;
for (const [key, entry] of entries) {
if (entry.expiresAt < now) {
entries.delete(key);
totalBytes -= entry.sizeBytes;
expirations++;
removed++;
}
}
return removed;
}
export function scheduleDailyCleanup(): NodeJS.Timeout {
return setInterval(pruneExpired, 24 * 60 * 60 * 1000);
}
export function scheduleHourlyCleanup(): NodeJS.Timeout {
return setInterval(pruneExpired, 60 * 60 * 1000);
}
function evictOldest(): void {
let oldest: CacheEntry<unknown> | null = null;
for (const entry of entries.values()) {
if (!oldest || entry.lastAccessedAt < oldest.lastAccessedAt) {
oldest = entry;
}
}
if (!oldest) return;
totalBytes -= oldest.sizeBytes;
entries.delete(oldest.key);
evictions++;
}
// ── Warming ────────────────────────────────────────────────────────────────
export async function warmCache<T>(specs: WarmingSpec<T>[]): Promise<void> {
await Promise.allSettled(
specs.map(async (spec) => {
const value = await spec.fetch();
set(spec.key, value, spec.sizeEstimate);
}),
);
}
// ── Stats ──────────────────────────────────────────────────────────────────
export function getStats(): CacheStats {
return {
entries: entries.size,
totalBytes,
hits,
misses,
evictions,
expirations,
};
}
export function resetStats(): void {
hits = 0;
misses = 0;
evictions = 0;
expirations = 0;
}
export function hitRate(): number {
const total = hits + misses;
return total === 0 ? 0 : hits / total;
}
export function keys(): string[] {
return Array.from(entries.keys());
}
export function byteUsagePct(): number {
return totalBytes / (100 * 1024 * 1024);
}
// ── Namespaced sub-cache ───────────────────────────────────────────────────
/**
* Returns a cache interface scoped to a namespace prefix. All keys are
* stored in the same underlying map with the prefix prepended, so
* `ns.set("x", ...)` and `globalGet("myns:x")` see the same entry.
*/
export function createNamespace(prefix: string) {
const ns = (key: string) => `${prefix}:${key}`;
return {
set<T>(key: string, value: T, sizeBytes: number): void {
set(ns(key), value, sizeBytes);
},
setWithTtl<T>(key: string, value: T, sizeBytes: number, ttlMs: number): void {
setWithTtl(ns(key), value, sizeBytes, ttlMs);
},
get<T>(key: string): T | null {
return get<T>(ns(key));
},
has(key: string): boolean {
return has(ns(key));
},
del(key: string): boolean {
return del(ns(key));
},
keys(): string[] {
return keys()
.filter((k) => k.startsWith(`${prefix}:`))
.map((k) => k.slice(prefix.length + 1));
},
clear(): void {
for (const k of keys().filter((k) => k.startsWith(`${prefix}:`))) {
del(k);
}
},
};
}
// ── Serialized access (write-through) ─────────────────────────────────────
/**
* Reads a value from cache, calling `fetch` on miss and storing the
* result. Concurrent calls for the same key each trigger an independent
* fetch; callers that need deduplication should use their own in-flight
* map on top of this.
*/
export async function getOrFetch<T>(
key: string,
fetch: () => Promise<T>,
sizeBytes: number,
ttlMs?: number,
): Promise<T> {
const cached = get<T>(key);
if (cached !== null) return cached;
const value = await fetch();
if (ttlMs !== undefined) {
setWithTtl(key, value, sizeBytes, ttlMs);
} else {
set(key, value, sizeBytes);
}
return value;
}
// ── Bulk operations ────────────────────────────────────────────────────────
export function getMany<T>(keys: string[]): Array<T | null> {
return keys.map((k) => get<T>(k));
}
export function delByPrefix(prefix: string): number {
const matching = keys().filter((k) => k.startsWith(prefix));
return delMany(matching);
}
interface TlsConfig {
cert?: string;
key?: string;
ca?: string;
rejectUnauthorized?: boolean;
}
interface ServerConfig {
host?: string;
port?: number;
tls?: TlsConfig;
keepAliveTimeoutMs?: number;
maxRequestBodyBytes?: number;
}
interface PoolConfig {
min?: number;
max?: number;
idleTimeoutMs?: number;
acquireTimeoutMs?: number;
}
interface DatabaseConfig {
url?: string;
pool?: PoolConfig;
statementTimeoutMs?: number;
ssl?: {
enabled?: boolean;
rejectUnauthorized?: boolean;
};
}
interface RedisConfig {
host?: string;
port?: number;
password?: string;
db?: number;
tls?: { enabled?: boolean };
maxRetriesPerRequest?: number;
}
interface LoggingConfig {
level?: "debug" | "info" | "warn" | "error";
format?: "json" | "text";
destination?: {
console?: boolean;
file?: { path?: string; maxSizeMb?: number; maxFiles?: number };
};
}
interface QueueConfig {
concurrency?: number;
maxRetries?: number;
backoffMs?: number;
visibilityTimeoutMs?: number;
deadLetterQueueName?: string;
}
interface RateLimitConfig {
windowMs?: number;
maxRequests?: number;
keyPrefix?: string;
skipSuccessfulRequests?: boolean;
}
interface FeatureFlagsConfig {
experimental?: {
newUi?: boolean;
betaSearch?: boolean;
streamingExport?: boolean;
};
rollout?: {
newOnboarding?: number;
improvedEditor?: number;
};
}
interface AppConfig {
server?: ServerConfig;
database?: DatabaseConfig;
redis?: RedisConfig;
logging?: LoggingConfig;
queue?: QueueConfig;
rateLimit?: RateLimitConfig;
features?: FeatureFlagsConfig;
}
// ── Server ─────────────────────────────────────────────────────────────────
export function getServerUrl(cfg: AppConfig): string {
const host = cfg.server.host;
const port = cfg.server.port;
const scheme = cfg.server.tls.cert ? "https" : "http";
return `${scheme}://${host}:${port}`;
}
export function getDatabasePoolSize(cfg: AppConfig): {
min: number;
max: number;
} {
return {
min: cfg.database.pool.min,
max: cfg.database.pool.max,
};
}
export function isExperimentalUiEnabled(cfg: AppConfig): boolean {
return cfg.features.experimental.newUi;
}
export function describeServer(cfg: AppConfig): string {
const certLen = cfg.server.tls.cert.length;
const keyLen = cfg.server.tls.key.length;
return `tls cert ${certLen} bytes, key ${keyLen} bytes`;
}
export function getServerKeepAliveMs(cfg: AppConfig): number {
return cfg.server.keepAliveTimeoutMs;
}
export function getMaxRequestBodyBytes(cfg: AppConfig): number {
return cfg.server.maxRequestBodyBytes;
}
export function isTlsCaRequired(cfg: AppConfig): boolean {
return cfg.server.tls.rejectUnauthorized;
}
// ── Database ───────────────────────────────────────────────────────────────
export function getDatabaseUrl(cfg: AppConfig): string {
return cfg.database.url;
}
export function getDatabaseStatementTimeoutMs(cfg: AppConfig): number {
return cfg.database.statementTimeoutMs;
}
export function isDatabaseSslEnabled(cfg: AppConfig): boolean {
return cfg.database.ssl.enabled;
}
export function getDatabasePoolIdleTimeoutMs(cfg: AppConfig): number {
return cfg.database.pool.idleTimeoutMs;
}
export function getDatabasePoolAcquireTimeoutMs(cfg: AppConfig): number {
return cfg.database.pool.acquireTimeoutMs;
}
// ── Redis ──────────────────────────────────────────────────────────────────
export function getRedisHost(cfg: AppConfig): string {
return cfg.redis.host;
}
export function getRedisPort(cfg: AppConfig): number {
return cfg.redis.port;
}
export function getRedisPassword(cfg: AppConfig): string {
return cfg.redis.password;
}
export function getRedisDb(cfg: AppConfig): number {
return cfg.redis.db;
}
export function isRedisTlsEnabled(cfg: AppConfig): boolean {
return cfg.redis.tls.enabled;
}
export function getRedisMaxRetries(cfg: AppConfig): number {
return cfg.redis.maxRetriesPerRequest;
}
// ── Logging ────────────────────────────────────────────────────────────────
export function getLogLevel(cfg: AppConfig): string {
return cfg.logging.level;
}
export function getLogFormat(cfg: AppConfig): string {
return cfg.logging.format;
}
export function isConsoleLoggingEnabled(cfg: AppConfig): boolean {
return cfg.logging.destination.console;
}
export function getLogFilePath(cfg: AppConfig): string {
return cfg.logging.destination.file.path;
}
export function getLogFileMaxSizeMb(cfg: AppConfig): number {
return cfg.logging.destination.file.maxSizeMb;
}
export function getLogFileMaxFiles(cfg: AppConfig): number {
return cfg.logging.destination.file.maxFiles;
}
// ── Queue ──────────────────────────────────────────────────────────────────
export function getQueueConcurrency(cfg: AppConfig): number {
return cfg.queue.concurrency;
}
export function getQueueMaxRetries(cfg: AppConfig): number {
return cfg.queue.maxRetries;
}
export function getQueueBackoffMs(cfg: AppConfig): number {
return cfg.queue.backoffMs;
}
export function getQueueVisibilityTimeoutMs(cfg: AppConfig): number {
return cfg.queue.visibilityTimeoutMs;
}
export function getDeadLetterQueueName(cfg: AppConfig): string {
return cfg.queue.deadLetterQueueName;
}
// ── Rate limit ─────────────────────────────────────────────────────────────
export function getRateLimitWindowMs(cfg: AppConfig): number {
return cfg.rateLimit.windowMs;
}
export function getRateLimitMaxRequests(cfg: AppConfig): number {
return cfg.rateLimit.maxRequests;
}
export function getRateLimitKeyPrefix(cfg: AppConfig): string {
return cfg.rateLimit.keyPrefix;
}
export function isSkipSuccessfulRequestsEnabled(cfg: AppConfig): boolean {
return cfg.rateLimit.skipSuccessfulRequests;
}
// ── Feature flags ──────────────────────────────────────────────────────────
export function isBetaSearchEnabled(cfg: AppConfig): boolean {
return cfg.features.experimental.betaSearch;
}
export function isStreamingExportEnabled(cfg: AppConfig): boolean {
return cfg.features.experimental.streamingExport;
}
export function getNewOnboardingRolloutPct(cfg: AppConfig): number {
return cfg.features.rollout.newOnboarding;
}
export function getImprovedEditorRolloutPct(cfg: AppConfig): number {
return cfg.features.rollout.improvedEditor;
}
// ── Composite helpers ──────────────────────────────────────────────────────
export function describeConfig(cfg: AppConfig): string {
const host = cfg.server.host;
const port = cfg.server.port;
const dbUrl = cfg.database.url;
const logLevel = cfg.logging.level;
const redisHost = cfg.redis.host;
return `server=${host}:${port} db=${dbUrl} log=${logLevel} redis=${redisHost}`;
}
export function getEffectiveLogDestinations(cfg: AppConfig): string[] {
const destinations: string[] = [];
if (cfg.logging.destination.console) {
destinations.push("console");
}
const filePath = cfg.logging.destination.file.path;
if (filePath) {
destinations.push(`file:${filePath}`);
}
return destinations;
}
export function isProductionLike(cfg: AppConfig): boolean {
const level = cfg.logging.level;
return level === "warn" || level === "error";
}
export function getFullRedisConnectionString(cfg: AppConfig): string {
const host = cfg.redis.host;
const port = cfg.redis.port;
const db = cfg.redis.db;
const useTls = cfg.redis.tls.enabled;
const scheme = useTls ? "rediss" : "redis";
return `${scheme}://${host}:${port}/${db}`;
}
export function getDatabaseSslRejectUnauthorized(cfg: AppConfig): boolean {
return cfg.database.ssl.rejectUnauthorized;
}
export function getTlsCaPath(cfg: AppConfig): string {
return cfg.server.tls.ca;
}
export function getRateLimitConfig(cfg: AppConfig): {
windowMs: number;
maxRequests: number;
keyPrefix: string;
skipSuccessful: boolean;
} {
return {
windowMs: cfg.rateLimit.windowMs,
maxRequests: cfg.rateLimit.maxRequests,
keyPrefix: cfg.rateLimit.keyPrefix,
skipSuccessful: cfg.rateLimit.skipSuccessfulRequests,
};
}
export function getQueueConfig(cfg: AppConfig): {
concurrency: number;
maxRetries: number;
backoffMs: number;
visibilityTimeoutMs: number;
} {
return {
concurrency: cfg.queue.concurrency,
maxRetries: cfg.queue.maxRetries,
backoffMs: cfg.queue.backoffMs,
visibilityTimeoutMs: cfg.queue.visibilityTimeoutMs,
};
}
// contact_book.ts — in-memory contact book with import/export and search.
export interface Contact {
id: string;
name: string; // e.g. "Ada Lovelace"
email: string;
phone: string;
tags: string[];
starred: boolean;
createdAt: string; // ISO timestamp
}
export interface ContactBook {
contacts: Contact[];
}
// ── Construction ───────────────────────────────────────────────────────────
export function createContact(input: {
id: string;
name: string;
email: string;
phone?: string;
tags?: string[];
starred?: boolean;
}): Contact {
return {
id: input.id,
name: input.name.trim(),
email: input.email.trim().toLowerCase(),
phone: input.phone?.trim() ?? "",
tags: input.tags?.slice() ?? [],
starred: input.starred ?? false,
createdAt: new Date().toISOString(),
};
}
export function emptyBook(): ContactBook {
return { contacts: [] };
}
export function addContact(book: ContactBook, contact: Contact): ContactBook {
return { contacts: [...book.contacts, contact] };
}
export function removeContact(book: ContactBook, id: string): ContactBook {
return { contacts: book.contacts.filter((c) => c.id !== id) };
}
// ── Display ────────────────────────────────────────────────────────────────
export function displayName(contact: Contact): string {
return contact.name;
}
export function lastFirstDisplay(contact: Contact): string {
// "Ada Lovelace" → "Lovelace, Ada"
const parts = contact.name.trim().split(/\s+/);
if (parts.length < 2) return contact.name;
const last = parts[parts.length - 1];
const rest = parts.slice(0, -1).join(" ");
return `${last}, ${rest}`;
}
export function initials(contact: Contact): string {
const parts = contact.name.trim().split(/\s+/);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
const first = parts[0].charAt(0).toUpperCase();
const last = parts[parts.length - 1].charAt(0).toUpperCase();
return `${first}${last}`;
}
export function formatLine(contact: Contact): string {
const star = contact.starred ? "★ " : "";
return `${star}${contact.name} <${contact.email}>`;
}
// ── Search & filter ────────────────────────────────────────────────────────
export function findById(book: ContactBook, id: string): Contact | null {
return book.contacts.find((c) => c.id === id) ?? null;
}
export function searchByName(book: ContactBook, query: string): Contact[] {
const q = query.trim().toLowerCase();
if (q === "") return [];
return book.contacts.filter((c) => c.name.toLowerCase().includes(q));
}
export function searchByEmail(book: ContactBook, query: string): Contact[] {
const q = query.trim().toLowerCase();
if (q === "") return [];
return book.contacts.filter((c) => c.email.toLowerCase().includes(q));
}
export function starredContacts(book: ContactBook): Contact[] {
return book.contacts.filter((c) => c.starred);
}
export function contactsByTag(book: ContactBook, tag: string): Contact[] {
return book.contacts.filter((c) => c.tags.includes(tag));
}
// ── Sorting ────────────────────────────────────────────────────────────────
export function sortByName(book: ContactBook): ContactBook {
const sorted = [...book.contacts].sort((a, b) => {
const an = a.name.toLowerCase();
const bn = b.name.toLowerCase();
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
return { contacts: sorted };
}
export function sortByLastName(book: ContactBook): ContactBook {
const keyOf = (c: Contact): string => {
const parts = c.name.trim().split(/\s+/);
return parts.length === 0 ? "" : parts[parts.length - 1].toLowerCase();
};
const sorted = [...book.contacts].sort((a, b) => {
const ak = keyOf(a);
const bk = keyOf(b);
if (ak < bk) return -1;
if (ak > bk) return 1;
return 0;
});
return { contacts: sorted };
}
// ── CSV import/export ──────────────────────────────────────────────────────
export function toCsv(book: ContactBook): string {
const rows = ["name,email,phone,tags,starred"];
for (const c of book.contacts) {
const tags = c.tags.join("|");
rows.push(
[c.name, c.email, c.phone, tags, c.starred ? "true" : "false"].join(","),
);
}
return rows.join("\n");
}
export function fromCsv(csv: string): ContactBook {
const lines = csv.split("\n").filter((l) => l.trim() !== "");
if (lines.length <= 1) return emptyBook();
const contacts: Contact[] = [];
for (let i = 1; i < lines.length; i++) {
const [name, email, phone, tagsCsv, starredStr] = lines[i].split(",");
contacts.push({
id: `csv-${i}`,
name: name?.trim() ?? "",
email: email?.trim().toLowerCase() ?? "",
phone: phone?.trim() ?? "",
tags: tagsCsv ? tagsCsv.split("|").filter((t) => t !== "") : [],
starred: starredStr?.trim() === "true",
createdAt: new Date().toISOString(),
});
}
return { contacts };
}
// ── Deduplication ──────────────────────────────────────────────────────────
export function dedupeByName(book: ContactBook): ContactBook {
const seen = new Set<string>();
const contacts: Contact[] = [];
for (const c of book.contacts) {
const key = c.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
contacts.push(c);
}
return { contacts };
}
// ── Validation ─────────────────────────────────────────────────────────────
export function validateContact(contact: Contact): string[] {
const errors: string[] = [];
if (contact.name.trim() === "") {
errors.push("name is required");
}
if (!contact.email.includes("@")) {
errors.push("email must contain @");
}
return errors;
}
// ── Rendering helpers ──────────────────────────────────────────────────────
export function renderDirectory(book: ContactBook): string {
const sorted = sortByLastName(book);
return sorted.contacts
.map((c) => ` • ${lastFirstDisplay(c)}${c.email}`)
.join("\n");
}
export function greetingFor(contact: Contact): string {
const first = contact.name.split(" ")[0];
return `Hello, ${first || "there"}!`;
}
// ── Merge ──────────────────────────────────────────────────────────────────
export function mergeBooks(a: ContactBook, b: ContactBook): ContactBook {
const merged = [...a.contacts, ...b.contacts];
return dedupeByName({ contacts: merged });
}
// event_handler.ts — dispatches application events to their handlers
import { createLogger } from "./logger";
const logger = createLogger("event-handler");
export type EventType =
| "user.created"
| "user.updated"
| "user.deleted"
| "user.deactivated"
| "user.role_changed"
| "project.created"
| "project.updated"
| "project.archived"
| "project.deleted"
| "project.member_added"
| "project.member_removed"
| "payment.succeeded"
| "payment.failed"
| "payment.refunded"
| "subscription.created"
| "subscription.cancelled"
| "subscription.renewed";
export interface AppEvent {
type: EventType;
payload: Record<string, unknown>;
timestamp: string;
correlationId: string;
sourceService: string;
}
// ── Individual handlers ────────────────────────────────────────────────────
async function notifyUserCreated(payload: Record<string, unknown>): Promise<void> {
logger.info(`Sending welcome email to ${payload.email}`);
// implementation elided
}
async function syncUserToSearchIndex(payload: Record<string, unknown>): Promise<void> {
logger.info(`Syncing user ${payload.id} to search index`);
// implementation elided
}
async function revokeUserSessions(payload: Record<string, unknown>): Promise<void> {
logger.info(`Revoking all sessions for user ${payload.id}`);
// implementation elided
}
async function notifyUserRoleChanged(payload: Record<string, unknown>): Promise<void> {
logger.info(`Notifying user ${payload.id} of role change: ${payload.oldRole}${payload.newRole}`);
// implementation elided
}
async function notifyProjectCreated(payload: Record<string, unknown>): Promise<void> {
logger.info(`Notifying team about new project ${payload.id}`);
// implementation elided
}
async function archiveProjectAssets(payload: Record<string, unknown>): Promise<void> {
logger.info(`Archiving assets for project ${payload.id}`);
// implementation elided
}
async function cleanupProjectResources(payload: Record<string, unknown>): Promise<void> {
logger.info(`Cleaning up resources for deleted project ${payload.id}`);
// implementation elided
}
async function notifyProjectMemberAdded(payload: Record<string, unknown>): Promise<void> {
logger.info(`Notifying user ${payload.memberId} they were added to project ${payload.projectId}`);
// implementation elided
}
async function notifyProjectMemberRemoved(payload: Record<string, unknown>): Promise<void> {
logger.info(`Notifying user ${payload.memberId} they were removed from project ${payload.projectId}`);
// implementation elided
}
async function recordPaymentSuccess(payload: Record<string, unknown>): Promise<void> {
logger.info(`Recording payment ${payload.transactionId}`);
// implementation elided
}
async function handlePaymentFailure(payload: Record<string, unknown>): Promise<void> {
logger.warn(`Payment failed for order ${payload.orderId}`);
// implementation elided
}
async function processRefund(payload: Record<string, unknown>): Promise<void> {
logger.info(`Processing refund ${payload.refundId} for transaction ${payload.transactionId}`);
// implementation elided
}
async function provisionSubscriptionFeatures(payload: Record<string, unknown>): Promise<void> {
logger.info(`Provisioning features for subscription ${payload.subscriptionId}`);
// implementation elided
}
async function deprovisionSubscriptionFeatures(payload: Record<string, unknown>): Promise<void> {
logger.info(`Deprovisioning features for cancelled subscription ${payload.subscriptionId}`);
// implementation elided
}
async function extendSubscriptionAccess(payload: Record<string, unknown>): Promise<void> {
logger.info(`Extending access for renewed subscription ${payload.subscriptionId}`);
// implementation elided
}
/**
* Routes an application event to the correct handler.
* TODO: Refactor this switch into a Record<EventType, handler> map + dispatch function.
*/
export async function handleEvent(event: AppEvent): Promise<void> {
logger.info(`Handling event ${event.type} (correlation: ${event.correlationId}, source: ${event.sourceService})`);
switch (event.type) {
case "user.created":
await notifyUserCreated(event.payload);
await syncUserToSearchIndex(event.payload);
break;
case "user.updated":
await syncUserToSearchIndex(event.payload);
break;
case "user.deleted":
logger.info(`User ${event.payload.id} deleted — cleaning up`);
await revokeUserSessions(event.payload);
break;
case "user.deactivated":
logger.info(`User ${event.payload.id} deactivated`);
await revokeUserSessions(event.payload);
break;
case "user.role_changed":
await notifyUserRoleChanged(event.payload);
break;
case "project.created":
await notifyProjectCreated(event.payload);
break;
case "project.updated":
logger.info(`Project ${event.payload.id} updated`);
break;
case "project.archived":
await archiveProjectAssets(event.payload);
break;
case "project.deleted":
await cleanupProjectResources(event.payload);
break;
case "project.member_added":
await notifyProjectMemberAdded(event.payload);
break;
case "project.member_removed":
await notifyProjectMemberRemoved(event.payload);
break;
case "payment.succeeded":
await recordPaymentSuccess(event.payload);
break;
case "payment.failed":
await handlePaymentFailure(event.payload);
break;
case "payment.refunded":
await processRefund(event.payload);
break;
case "subscription.created":
await provisionSubscriptionFeatures(event.payload);
break;
case "subscription.cancelled":
await deprovisionSubscriptionFeatures(event.payload);
break;
case "subscription.renewed":
await extendSubscriptionAccess(event.payload);
break;
default: {
const exhaustiveCheck: never = event.type;
logger.warn(`Unknown event type: ${exhaustiveCheck}`);
}
}
}
// ── Batch processing ───────────────────────────────────────────────────────
export interface EventBatch {
events: AppEvent[];
batchId: string;
enqueuedAt: string;
}
export interface BatchResult {
batchId: string;
total: number;
succeeded: number;
failed: number;
errors: Array<{ correlationId: string; message: string }>;
}
/**
* Processes a batch of events sequentially. Failures are recorded but
* do not abort the remaining events in the batch.
*/
export async function handleEventBatch(batch: EventBatch): Promise<BatchResult> {
logger.info(
`Processing batch ${batch.batchId} with ${batch.events.length} event(s)`,
);
const result: BatchResult = {
batchId: batch.batchId,
total: batch.events.length,
succeeded: 0,
failed: 0,
errors: [],
};
for (const event of batch.events) {
try {
await handleEvent(event);
result.succeeded++;
} catch (err) {
result.failed++;
result.errors.push({
correlationId: event.correlationId,
message: err instanceof Error ? err.message : String(err),
});
logger.error(
`Batch ${batch.batchId}: event ${event.correlationId} (${event.type}) failed`,
err,
);
}
}
logger.info(
`Batch ${batch.batchId} complete — succeeded: ${result.succeeded}, failed: ${result.failed}`,
);
return result;
}
// ── Dead-letter retry ──────────────────────────────────────────────────────
interface DeadLetterEntry {
event: AppEvent;
failedAt: string;
reason: string;
attempts: number;
}
const MAX_RETRY_ATTEMPTS = 3;
export async function retryDeadLetter(
entries: DeadLetterEntry[],
): Promise<{ retried: number; exhausted: number }> {
let retried = 0;
let exhausted = 0;
for (const entry of entries) {
if (entry.attempts >= MAX_RETRY_ATTEMPTS) {
logger.warn(
`Dead-letter entry for ${entry.event.correlationId} exhausted (${entry.attempts} attempts)`,
);
exhausted++;
continue;
}
try {
await handleEvent(entry.event);
retried++;
logger.info(`Retried dead-letter event ${entry.event.correlationId} successfully`);
} catch (err) {
logger.error(
`Retry failed for dead-letter event ${entry.event.correlationId}`,
err,
);
}
}
return { retried, exhausted };
}
// ── Metrics ────────────────────────────────────────────────────────────────
const handledCounts = new Map<EventType, number>();
export function getHandledCount(type: EventType): number {
return handledCounts.get(type) ?? 0;
}
export function resetHandledCounts(): void {
handledCounts.clear();
logger.info("Event handled counts reset");
}
export function getAllHandledCounts(): Record<string, number> {
return Object.fromEntries(handledCounts.entries());
}
// fetch_client.ts — authenticated fetch wrapper used by all service layers
import { createLogger } from "./logger";
import { getAuthToken } from "./auth";
const logger = createLogger("fetch-client");
const BASE_URL = process.env.SERVICE_BASE_URL ?? "https://api.internal.example.com";
const DEFAULT_TIMEOUT_MS = 8_000;
export interface FetchClientOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
headers?: Record<string, string>;
timeoutMs?: number;
retries?: number;
}
export interface ServiceError {
code: string;
message: string;
status: number;
}
export interface PagedResponse<T> {
items: T[];
total: number;
page: number;
hasMore: boolean;
}
export interface UserProfile {
id: string;
name: string;
email: string;
role: string;
avatarUrl: string | null;
createdAt: string;
}
export interface Project {
id: string;
name: string;
status: string;
ownerId: string;
createdAt: string;
}
export interface ProjectMember {
userId: string;
name: string;
email: string;
role: string;
}
export interface Subscription {
id: string;
plan: string;
status: string;
renewsAt: string | null;
}
/**
* Sends an authenticated request to the internal service API.
* Throws a ServiceError on non-2xx responses.
*/
export async function serviceRequest<T>(
path: string,
options: FetchClientOptions = {},
): Promise<T> {
const { method = "GET", body, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
const token = await getAuthToken();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...headers,
},
body: body != null ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw {
code: errorBody.code ?? "UNKNOWN_ERROR",
message: errorBody.message ?? response.statusText,
status: response.status,
} satisfies ServiceError;
}
return response.json() as Promise<T>;
}
// ── Verb wrappers ──────────────────────────────────────────────────────────
export async function getResource<T>(path: string, headers?: Record<string, string>): Promise<T> {
const data = await serviceRequest<T>(path, { method: "GET", headers });
return data;
}
export async function postResource<T>(path: string, body: unknown): Promise<T> {
const data = await serviceRequest<T>(path, { method: "POST", body });
return data;
}
export async function putResource<T>(path: string, body: unknown): Promise<T> {
const data = await serviceRequest<T>(path, { method: "PUT", body });
return data;
}
export async function patchResource<T>(path: string, body: unknown): Promise<T> {
const data = await serviceRequest<T>(path, { method: "PATCH", body });
return data;
}
export async function deleteResource(path: string): Promise<void> {
const data = await serviceRequest<void>(path, { method: "DELETE" });
return data;
}
// ── User resources ─────────────────────────────────────────────────────────
export async function getUserProfile(userId: string): Promise<UserProfile> {
return getResource<UserProfile>(`/users/${userId}`);
}
export async function updateUserProfile(
userId: string,
updates: { name?: string; email?: string; avatarUrl?: string },
): Promise<UserProfile> {
return patchResource<UserProfile>(`/users/${userId}`, updates);
}
export async function deleteUserAccount(userId: string): Promise<void> {
return deleteResource(`/users/${userId}`);
}
export async function listUsers(
page = 1,
limit = 20,
): Promise<PagedResponse<UserProfile>> {
return getResource<PagedResponse<UserProfile>>(
`/users?page=${page}&limit=${limit}`,
);
}
export async function getUserSubscription(userId: string): Promise<Subscription | null> {
return getResource<Subscription | null>(`/users/${userId}/subscription`);
}
// ── Project resources ──────────────────────────────────────────────────────
export async function getProjectList(workspaceId: string): Promise<Project[]> {
return getResource<Project[]>(`/workspaces/${workspaceId}/projects`);
}
export async function createProject(
workspaceId: string,
payload: { name: string; template?: string },
): Promise<Project> {
return postResource<Project>(`/workspaces/${workspaceId}/projects`, payload);
}
export async function getProject(projectId: string): Promise<Project> {
return getResource<Project>(`/projects/${projectId}`);
}
export async function updateProject(
projectId: string,
updates: { name?: string; status?: string },
): Promise<Project> {
return patchResource<Project>(`/projects/${projectId}`, updates);
}
export async function archiveProject(projectId: string): Promise<void> {
return postResource<void>(`/projects/${projectId}/archive`, {});
}
export async function deleteProject(projectId: string): Promise<void> {
return deleteResource(`/projects/${projectId}`);
}
// ── Project membership ─────────────────────────────────────────────────────
export async function getProjectMembers(projectId: string): Promise<ProjectMember[]> {
return getResource<ProjectMember[]>(`/projects/${projectId}/members`);
}
export async function addProjectMember(
projectId: string,
userId: string,
role: string,
): Promise<void> {
return postResource<void>(`/projects/${projectId}/members`, { userId, role });
}
export async function removeProjectMember(
projectId: string,
userId: string,
): Promise<void> {
return deleteResource(`/projects/${projectId}/members/${userId}`);
}
export async function updateProjectMemberRole(
projectId: string,
userId: string,
role: string,
): Promise<ProjectMember> {
return patchResource<ProjectMember>(`/projects/${projectId}/members/${userId}`, { role });
}
// ── Workspace resources ────────────────────────────────────────────────────
export async function getWorkspace(workspaceId: string): Promise<{ id: string; name: string; plan: string }> {
return getResource(`/workspaces/${workspaceId}`);
}
export async function updateWorkspace(
workspaceId: string,
updates: { name?: string },
): Promise<{ id: string; name: string }> {
return patchResource(`/workspaces/${workspaceId}`, updates);
}
// ── Billing resources ──────────────────────────────────────────────────────
export interface Invoice {
id: string;
amount: number;
currency: string;
status: "draft" | "open" | "paid" | "void";
dueDate: string;
createdAt: string;
}
export interface PaymentMethod {
id: string;
type: "card" | "bank_account";
last4: string;
expMonth?: number;
expYear?: number;
isDefault: boolean;
}
export async function listInvoices(workspaceId: string): Promise<Invoice[]> {
return getResource<Invoice[]>(`/workspaces/${workspaceId}/billing/invoices`);
}
export async function getInvoice(invoiceId: string): Promise<Invoice> {
return getResource<Invoice>(`/billing/invoices/${invoiceId}`);
}
export async function downloadInvoicePdf(invoiceId: string): Promise<Blob> {
const token = await getAuthToken();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
const response = await fetch(`${BASE_URL}/billing/invoices/${invoiceId}/pdf`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
throw { code: "DOWNLOAD_FAILED", message: "Failed to download invoice PDF", status: response.status } satisfies ServiceError;
}
return response.blob();
}
export async function listPaymentMethods(workspaceId: string): Promise<PaymentMethod[]> {
return getResource<PaymentMethod[]>(`/workspaces/${workspaceId}/billing/payment-methods`);
}
export async function addPaymentMethod(
workspaceId: string,
token: string,
): Promise<PaymentMethod> {
return postResource<PaymentMethod>(`/workspaces/${workspaceId}/billing/payment-methods`, { token });
}
export async function removePaymentMethod(
workspaceId: string,
paymentMethodId: string,
): Promise<void> {
return deleteResource(`/workspaces/${workspaceId}/billing/payment-methods/${paymentMethodId}`);
}
export async function setDefaultPaymentMethod(
workspaceId: string,
paymentMethodId: string,
): Promise<void> {
return postResource<void>(
`/workspaces/${workspaceId}/billing/payment-methods/${paymentMethodId}/set-default`,
{},
);
}
// order_math.ts — order total calculation and related helpers
interface LineItem {
sku: string;
quantity: number;
unitPrice: number;
weight: number; // grams
taxable: boolean;
discountable: boolean;
}
interface Coupon {
code: string;
type: "pct" | "fixed";
value: number; // percent (0-100) or absolute USD
minimumOrderValue: number;
appliesToShipping: boolean;
}
interface ShippingRate {
carrier: string;
service: string;
rateUsd: number;
estimatedDays: number;
}
interface Order {
items: LineItem[];
discountPct: number;
taxRate: number;
shippingCost: number;
coupon?: Coupon;
currency: string;
notes?: string;
}
interface OrderSummary {
subtotal: number;
discountAmount: number;
taxableAmount: number;
taxAmount: number;
shippingCost: number;
couponSavings: number;
total: number;
itemCount: number;
}
// ── Core calculation ───────────────────────────────────────────────────────
export function calculateTotal(order: Order): number {
const subtotal = subtotalOf(order.items);
const afterDiscount = subtotal * (1 - order.discountPct);
const withTax = afterDiscount * (1 + order.taxRate);
return withTax + order.shippingCost;
}
export function subtotalOf(items: LineItem[]): number {
let sum = 0;
for (const item of items) {
sum += item.quantity * item.unitPrice;
}
return sum;
}
export function discountableSubtotal(items: LineItem[]): number {
let sum = 0;
for (const item of items) {
if (item.discountable) {
sum += item.quantity * item.unitPrice;
}
}
return sum;
}
export function taxableSubtotal(items: LineItem[]): number {
let sum = 0;
for (const item of items) {
if (item.taxable) {
sum += item.quantity * item.unitPrice;
}
}
return sum;
}
export function totalWeightGrams(items: LineItem[]): number {
let grams = 0;
for (const item of items) {
grams += item.quantity * item.weight;
}
return grams;
}
// ── Coupon helpers ─────────────────────────────────────────────────────────
export function applyCoupon(order: Order, subtotal: number): number {
if (!order.coupon) return 0;
const { coupon } = order;
// Shipping-only coupons are applied via effectiveShippingCost; returning
// the coupon value here too would double-count the discount in buildSummary.
if (coupon.appliesToShipping) return 0;
if (subtotal < coupon.minimumOrderValue) return 0;
if (coupon.type === "fixed") return Math.min(coupon.value, subtotal);
return subtotal * (coupon.value / 100);
}
export function couponAppliestoShipping(order: Order): boolean {
return !!order.coupon?.appliesToShipping;
}
export function effectiveShippingCost(order: Order): number {
const base = order.shippingCost;
if (!order.coupon?.appliesToShipping) return base;
if (order.coupon.type === "fixed") return Math.max(0, base - order.coupon.value);
return base * (1 - order.coupon.value / 100);
}
// ── Breakdown & description ────────────────────────────────────────────────
export function buildSummary(order: Order): OrderSummary {
const subtotal = subtotalOf(order.items);
const discountAmount = subtotal * order.discountPct;
const afterDiscount = subtotal - discountAmount;
const taxableAmount = taxableSubtotal(order.items) * (1 - order.discountPct);
const taxAmount = taxableAmount * order.taxRate;
const couponSavings = applyCoupon(order, afterDiscount);
const shipping = effectiveShippingCost(order);
const total = afterDiscount + taxAmount - couponSavings + shipping;
return {
subtotal,
discountAmount,
taxableAmount,
taxAmount,
shippingCost: shipping,
couponSavings,
total,
itemCount: order.items.reduce((n, i) => n + i.quantity, 0),
};
}
export function describeOrder(order: Order): string {
const total = calculateTotal(order);
return `Order of ${order.items.length} items, total $${total.toFixed(2)}`;
}
export function validateOrder(order: Order): void {
if (order.items.length === 0) {
throw new Error("calculateTotal failed: order has no items");
}
const total = calculateTotal(order);
if (total < 0) {
throw new Error(`calculateTotal returned negative value: ${total}`);
}
}
export function summarizeOrder(order: Order): { items: number; total: number } {
return {
items: order.items.length,
total: calculateTotal(order),
};
}
export function compareOrders(a: Order, b: Order): number {
return calculateTotal(a) - calculateTotal(b);
}
export function cheapestShipping(rates: ShippingRate[]): ShippingRate | null {
if (rates.length === 0) return null;
return rates.reduce((best, r) => (r.rateUsd < best.rateUsd ? r : best));
}
export function formatOrderLine(item: LineItem): string {
return `${item.sku} × ${item.quantity} @ $${item.unitPrice.toFixed(2)}`;
}
export function applyBulkDiscount(items: LineItem[], threshold: number, pct: number): number {
const sub = subtotalOf(items);
if (sub < threshold) return sub;
return sub * (1 - pct);
}
export function estimateTax(order: Order): number {
const taxable = taxableSubtotal(order.items);
const afterDiscount = taxable * (1 - order.discountPct);
return afterDiscount * order.taxRate;
}
export function orderContainsSku(order: Order, sku: string): boolean {
return order.items.some((i) => i.sku === sku);
}
export function totalForSku(order: Order, sku: string): number {
return order.items
.filter((i) => i.sku === sku)
.reduce((n, i) => n + i.quantity * i.unitPrice, 0);
}
export function mergeOrders(orders: Order[]): Order {
if (orders.length === 0) {
throw new Error("calculateTotal failed: cannot merge empty order list");
}
const base = orders[0];
return {
...base,
items: orders.flatMap((o) => o.items),
shippingCost: orders.reduce((n, o) => n + o.shippingCost, 0),
};
}
export function printOrderTotals(orders: Order[]): void {
for (const order of orders) {
const total = calculateTotal(order);
console.log(` ${order.currency} ${total.toFixed(2)}`);
}
}
// ── Multi-currency support ─────────────────────────────────────────────────
const EXCHANGE_RATES: Record<string, number> = {
USD: 1.0,
EUR: 0.92,
GBP: 0.79,
CAD: 1.36,
AUD: 1.53,
JPY: 154.0,
};
export function convertTotal(order: Order, targetCurrency: string): number {
const total = calculateTotal(order);
const fromRate = EXCHANGE_RATES[order.currency] ?? 1;
const toRate = EXCHANGE_RATES[targetCurrency] ?? 1;
return (total / fromRate) * toRate;
}
export function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(amount);
}
// ── Refund calculations ────────────────────────────────────────────────────
export function refundAmountForItems(
order: Order,
returnedSkus: string[],
): number {
const returnedItems = order.items.filter((i) => returnedSkus.includes(i.sku));
if (returnedItems.length === 0) return 0;
const returnedSubtotal = subtotalOf(returnedItems);
const totalSubtotal = subtotalOf(order.items);
if (totalSubtotal === 0) return 0;
const discountFraction = order.discountPct;
const afterDiscount = returnedSubtotal * (1 - discountFraction);
const taxable = returnedItems.filter((i) => i.taxable);
const taxableSubtotalReturned = subtotalOf(taxable) * (1 - discountFraction);
const tax = taxableSubtotalReturned * order.taxRate;
return afterDiscount + tax;
}
export function isFullRefund(order: Order, returnedSkus: string[]): boolean {
const allSkus = order.items.map((i) => i.sku);
return allSkus.every((sku) => returnedSkus.includes(sku));
}
// ── Reporting helpers ──────────────────────────────────────────────────────
export function totalsByCurrency(
orders: Order[],
): Record<string, number> {
const totals: Record<string, number> = {};
for (const order of orders) {
const key = order.currency;
totals[key] = (totals[key] ?? 0) + calculateTotal(order);
}
return totals;
}
export function averageOrderValue(orders: Order[]): number {
if (orders.length === 0) return 0;
const sum = orders.reduce((n, o) => n + calculateTotal(o), 0);
return sum / orders.length;
}
export function topOrdersByValue(orders: Order[], n: number): Order[] {
return [...orders].sort((a, b) => calculateTotal(b) - calculateTotal(a)).slice(0, n);
}
export function totalRevenue(orders: Order[]): number {
return orders.reduce((sum, o) => sum + calculateTotal(o), 0);
}
export function ordersAboveThreshold(orders: Order[], threshold: number): Order[] {
return orders.filter((o) => calculateTotal(o) >= threshold);
}
export function ordersBelowThreshold(orders: Order[], threshold: number): Order[] {
return orders.filter((o) => calculateTotal(o) < threshold);
}
// permissions.ts — role-based access control policies
interface Policy {
canViewContent(): boolean;
canCreateContent(): boolean;
canEditContent(): boolean;
canDeleteContent(): boolean;
canViewUsers(): boolean;
canManageUsers(): boolean;
canViewRoles(): boolean;
canManageRoles(): boolean;
canViewAuditLog(): boolean;
canExportData(): boolean;
}
export class AdminPolicy implements Policy {
canViewContent(): boolean {
return true;
}
canCreateContent(): boolean {
return true;
}
canEditContent(): boolean {
return true;
}
canDeleteContent(): boolean {
return true;
}
canViewUsers(): boolean {
return true;
}
canManageUsers(): boolean {
return true;
}
canViewRoles(): boolean {
return true;
}
canManageRoles(): boolean {
return true;
}
canViewAuditLog(): boolean {
return true;
}
canExportData(): boolean {
return true;
}
}
export class ModeratorPolicy implements Policy {
canViewContent(): boolean {
return true;
}
canCreateContent(): boolean {
return true;
}
canEditContent(): boolean {
return true;
}
canDeleteContent(): boolean {
return true;
}
canViewUsers(): boolean {
return true;
}
canManageUsers(): boolean {
return true;
}
canViewRoles(): boolean {
return true;
}
canManageRoles(): boolean {
return false;
}
canViewAuditLog(): boolean {
return true;
}
canExportData(): boolean {
return true;
}
}
export function createPolicy(role: string): Policy {
switch (role) {
case "admin":
return new AdminPolicy();
case "moderator":
return new ModeratorPolicy();
default:
throw new Error(`Unknown role: ${role}`);
}
}
export function hasPermission(policy: Policy, action: string): boolean {
switch (action) {
case "view_content": return policy.canViewContent();
case "create_content": return policy.canCreateContent();
case "edit_content": return policy.canEditContent();
case "delete_content": return policy.canDeleteContent();
case "view_users": return policy.canViewUsers();
case "manage_users": return policy.canManageUsers();
case "view_roles": return policy.canViewRoles();
case "manage_roles": return policy.canManageRoles();
case "view_audit_log": return policy.canViewAuditLog();
case "export_data": return policy.canExportData();
default: return false;
}
}
import type { Request, Response } from "express";
import { db } from "./db";
import { logger } from "./logger";
interface AuthedRequest extends Request {
userId?: string;
}
// ── Project handlers ───────────────────────────────────────────────────────
export async function getProject(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`getProject called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`getProject called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const rows = await db.query("SELECT * FROM projects WHERE id = ?", [id]);
if (rows.length === 0) {
res.status(404).json({ error: "not found" });
return;
}
logger.info(`getProject(${id}) took ${Date.now() - start}ms`);
res.json(rows[0]);
}
export async function updateProject(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`updateProject called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`updateProject called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const name = req.body.name as string;
await db.query("UPDATE projects SET name = ? WHERE id = ?", [name, id]);
logger.info(`updateProject(${id}) took ${Date.now() - start}ms`);
res.json({ ok: true });
}
export async function deleteProject(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`deleteProject called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`deleteProject called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
await db.query("DELETE FROM projects WHERE id = ?", [id]);
logger.info(`deleteProject(${id}) took ${Date.now() - start}ms`);
res.json({ ok: true });
}
export async function archiveProject(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`archiveProject called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`archiveProject called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
await db.query(
"UPDATE projects SET status = 'archived', archived_at = ? WHERE id = ?",
[new Date().toISOString(), id],
);
logger.info(`archiveProject(${id}) took ${Date.now() - start}ms`);
res.json({ ok: true });
}
export async function getProjectMembers(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`getProjectMembers called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`getProjectMembers called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const rows = await db.query(
"SELECT u.id, u.name, u.email, pm.role FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = ?",
[id],
);
logger.info(`getProjectMembers(${id}) took ${Date.now() - start}ms`);
res.json({ members: rows });
}
export async function addProjectMember(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`addProjectMember called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`addProjectMember called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const { memberId, role } = req.body as { memberId: string; role: string };
await db.query(
"INSERT INTO project_members (project_id, user_id, role, added_at) VALUES (?, ?, ?, ?)",
[id, memberId, role, new Date().toISOString()],
);
logger.info(`addProjectMember(${id}, member=${memberId}) took ${Date.now() - start}ms`);
res.status(201).json({ ok: true });
}
export async function removeProjectMember(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`removeProjectMember called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`removeProjectMember called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const { memberId } = req.params;
await db.query(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
[id, memberId],
);
logger.info(`removeProjectMember(${id}, member=${memberId}) took ${Date.now() - start}ms`);
res.json({ ok: true });
}
export async function transferProjectOwnership(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`transferProjectOwnership called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`transferProjectOwnership called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const { newOwnerId } = req.body as { newOwnerId: string };
await db.query(
"UPDATE projects SET owner_id = ?, updated_at = ? WHERE id = ?",
[newOwnerId, new Date().toISOString(), id],
);
logger.info(`transferProjectOwnership(${id}, newOwner=${newOwnerId}) took ${Date.now() - start}ms`);
res.json({ ok: true });
}
export async function listProjectVersions(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`listProjectVersions called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`listProjectVersions called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const rows = await db.query(
"SELECT id, version, created_at, created_by FROM project_versions WHERE project_id = ? ORDER BY created_at DESC LIMIT 50",
[id],
);
logger.info(`listProjectVersions(${id}) took ${Date.now() - start}ms`);
res.json({ versions: rows });
}
export async function restoreProjectVersion(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`restoreProjectVersion called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`restoreProjectVersion called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const { versionId } = req.body as { versionId: string };
const versionRows = await db.query(
"SELECT * FROM project_versions WHERE id = ? AND project_id = ?",
[versionId, id],
);
if (versionRows.length === 0) {
res.status(404).json({ error: "version not found" });
return;
}
await db.query(
"UPDATE projects SET data = ?, updated_at = ? WHERE id = ?",
[(versionRows[0] as { data: unknown }).data, new Date().toISOString(), id],
);
logger.info(`restoreProjectVersion(${id}, version=${versionId}) took ${Date.now() - start}ms`);
res.json({ ok: true });
}
export async function duplicateProject(
req: AuthedRequest,
res: Response,
): Promise<void> {
const start = Date.now();
if (!req.userId) {
logger.warn(`duplicateProject called without userId from ${req.ip}`);
res.status(401).json({ error: "unauthorized" });
return;
}
const id = req.params.id;
if (!id || typeof id !== "string") {
logger.warn(`duplicateProject called with invalid id from user ${req.userId}`);
res.status(400).json({ error: "invalid id" });
return;
}
const rows = await db.query("SELECT * FROM projects WHERE id = ?", [id]);
if (rows.length === 0) {
res.status(404).json({ error: "not found" });
return;
}
const { name } = req.body as { name: string };
const newRows = await db.query(
"INSERT INTO projects (name, owner_id, status, data, created_at) SELECT ?, owner_id, 'active', data, ? FROM projects WHERE id = ? RETURNING id",
[name, new Date().toISOString(), id],
);
logger.info(`duplicateProject(${id}${(newRows[0] as { id: string }).id}) took ${Date.now() - start}ms`);
res.status(201).json(newRows[0]);
}
// stat_utils.ts — descriptive statistics and distance helpers
export function mean(xs: number[]): number {
if (xs.length === 0) {
return 0;
}
let sum = 0;
for (const x of xs) {
sum += x;
}
return sum / xs.length;
}
export function correlation(xs: number[], ys: number[]): number {
const sdX = stddev(xs);
const sdY = stddev(ys);
if (sdX === 0 || sdY === 0) return 0;
return covariance(xs, ys) / (sdX * sdY);
}
export function variance(xs: number[]): number {
if (xs.length < 2) return 0;
const mu = mean(xs);
let sum = 0;
for (const x of xs) {
sum += Math.pow(x - mu, 2);
}
return sum / (xs.length - 1);
}
export function correlation(xs: number[], ys: number[]): number {
const sdX = stddev(xs);
const sdY = stddev(ys);
if (sdX === 0 || sdY === 0) return 0;
return covariance(xs, ys) / (sdX * sdY);
}
export function populationVariance(xs: number[]): number {
if (xs.length === 0) {
return 0;
}
const mu = mean(xs);
let sum = 0;
for (const x of xs) {
sum += Math.pow(x - mu, 2);
}
return sum / xs.length;
}
export function stddev(xs: number[]): number {
return Math.sqrt(variance(xs));
}
export function populationStddev(xs: number[]): number {
return Math.sqrt(populationVariance(xs));
}
export function covariance(xs: number[], ys: number[]): number {
if (xs.length !== ys.length || xs.length < 2) return 0;
const muX = mean(xs);
const muY = mean(ys);
let sum = 0;
for (let i = 0; i < xs.length; i++) {
sum += (xs[i] - muX) * (ys[i] - muY);
}
return sum / (xs.length - 1);
}
export function correlation(xs: number[], ys: number[]): number {
const sdX = stddev(xs);
const sdY = stddev(ys);
if (sdX === 0 || sdY === 0) return 0;
return covariance(xs, ys) / (sdX * sdY);
}
export function skewness(xs: number[]): number {
if (xs.length < 3) return 0;
const mu = mean(xs);
const sd = stddev(xs);
if (sd === 0) return 0;
let sum = 0;
for (const x of xs) {
sum += Math.pow(x - mu, 3);
}
const n = xs.length;
return (n / ((n - 1) * (n - 2))) * (sum / Math.pow(sd, 3));
}
export function kurtosis(xs: number[]): number {
if (xs.length < 4) return 0;
const mu = mean(xs);
const sd = stddev(xs);
if (sd === 0) return 0;
let sum = 0;
for (const x of xs) {
sum += Math.pow(x - mu, 4);
}
const n = xs.length;
return sum / (n * Math.pow(sd, 4)) - 3;
}
export function mse(predicted: number[], actual: number[]): number {
if (predicted.length !== actual.length || predicted.length === 0) return 0;
let sum = 0;
for (let i = 0; i < predicted.length; i++) {
sum += Math.pow(predicted[i] - actual[i], 2);
}
return sum / predicted.length;
}
export function rmse(predicted: number[], actual: number[]): number {
return Math.sqrt(mse(predicted, actual));
}
export function rSquared(predicted: number[], actual: number[]): number {
if (predicted.length !== actual.length || predicted.length === 0) return 0;
const mu = mean(actual);
let ssTot = 0;
let ssRes = 0;
for (let i = 0; i < actual.length; i++) {
ssTot += Math.pow(actual[i] - mu, 2);
ssRes += Math.pow(predicted[i] - actual[i], 2);
}
if (ssTot === 0) return 1;
return 1 - ssRes / ssTot;
}
export function euclideanDistance(a: number[], b: number[]): number {
if (a.length !== b.length) return 0;
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += Math.pow(a[i] - b[i], 2);
}
return Math.sqrt(sum);
}
export function zScore(x: number, xs: number[]): number {
const sd = stddev(xs);
if (sd === 0) return 0;
return (x - mean(xs)) / sd;
}
export function median(xs: number[]): number {
if (xs.length === 0) {
return 0;
}
let sum = 0;
for (const x of xs) {
sum += x;
}
return sum / xs.length;
}
export function correlation(xs: number[], ys: number[]): number {
const sdX = stddev(xs);
const sdY = stddev(ys);
if (sdX === 0 || sdY === 0) return 0;
return covariance(xs, ys) / (sdX * sdY);
}
import type { Request, Response } from "express";
import { db } from "./db";
import { logger } from "./logger";
// ── Types ──────────────────────────────────────────────────────────────────
type UserRole = "admin" | "member" | "guest";
interface User {
id: string;
email: string;
name: string;
age: number;
role: UserRole;
createdAt: string;
updatedAt: string;
bio: string | null;
avatarUrl: string | null;
isActive: boolean;
}
interface CreateUserBody {
email: string;
name: string;
age: number;
role: UserRole;
bio?: string;
avatarUrl?: string;
}
interface UpdateUserBody {
name?: string;
age?: number;
role?: UserRole;
bio?: string;
avatarUrl?: string;
isActive?: boolean;
}
interface ListUsersQuery {
role?: UserRole;
isActive?: string;
page?: string;
limit?: string;
search?: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function parseIntParam(value: string | undefined, defaultVal: number): number {
if (!value) return defaultVal;
const n = parseInt(value, 10);
return isNaN(n) ? defaultVal : n;
}
function buildWhereClause(query: ListUsersQuery): {
sql: string;
params: unknown[];
} {
const conditions: string[] = [];
const params: unknown[] = [];
if (query.role) {
conditions.push("role = ?");
params.push(query.role);
}
if (query.isActive !== undefined) {
conditions.push("is_active = ?");
params.push(query.isActive === "true" ? 1 : 0);
}
if (query.search) {
conditions.push("(name LIKE ? OR email LIKE ?)");
const pattern = `%${query.search}%`;
params.push(pattern, pattern);
}
return {
sql: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
params,
};
}
// ── Handlers ───────────────────────────────────────────────────────────────
export async function createUserHandler(
req: Request,
res: Response,
): Promise<void> {
const email = req.body.email as string;
const name = req.body.name as string;
const age = req.body.age as number;
const role = req.body.role as "admin" | "member" | "guest";
const existing = await db.query("SELECT id FROM users WHERE email = ?", [
email,
]);
if (existing.length > 0) {
res.status(409).json({ error: "email already in use" });
return;
}
const rows = await db.query(
"INSERT INTO users (email, name, age, role) VALUES (?, ?, ?, ?) RETURNING *",
[email, name, age, role],
);
logger.info(`created user ${rows[0].id} with role ${role}`);
res.status(201).json(rows[0]);
}
export async function getUserHandler(
req: Request,
res: Response,
): Promise<void> {
const { id } = req.params;
if (!id) {
res.status(400).json({ error: "missing id" });
return;
}
const rows = await db.query("SELECT * FROM users WHERE id = ?", [id]);
if (rows.length === 0) {
res.status(404).json({ error: "user not found" });
return;
}
logger.info(`fetched user ${id}`);
res.json(rows[0]);
}
export async function listUsersHandler(
req: Request,
res: Response,
): Promise<void> {
const query = req.query as ListUsersQuery;
const page = parseIntParam(query.page, 1);
const limit = parseIntParam(query.limit, 20);
const offset = (page - 1) * limit;
const { sql: whereClause, params: whereParams } = buildWhereClause(query);
const countRows = await db.query(
`SELECT COUNT(*) AS total FROM users ${whereClause}`,
whereParams,
);
const total = (countRows[0] as { total: number }).total;
const rows = await db.query(
`SELECT * FROM users ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...whereParams, limit, offset],
);
const response: PaginatedResponse<User> = {
items: rows as User[],
total,
page,
limit,
hasMore: offset + rows.length < total,
};
logger.info(`listed ${rows.length} users (page=${page}, total=${total})`);
res.json(response);
}
export async function updateUserHandler(
req: Request,
res: Response,
): Promise<void> {
const { id } = req.params;
if (!id) {
res.status(400).json({ error: "missing id" });
return;
}
const existing = await db.query("SELECT * FROM users WHERE id = ?", [id]);
if (existing.length === 0) {
res.status(404).json({ error: "user not found" });
return;
}
const body = req.body as UpdateUserBody;
if (body.role && !["admin", "member", "guest"].includes(body.role)) {
res.status(400).json({ error: "invalid role" });
return;
}
if (body.age !== undefined && (typeof body.age !== "number" || body.age < 0)) {
res.status(400).json({ error: "age must be a non-negative number" });
return;
}
if (body.name !== undefined && body.name.trim().length === 0) {
res.status(400).json({ error: "name cannot be empty" });
return;
}
const setClauses: string[] = [];
const params: unknown[] = [];
for (const [key, value] of Object.entries(body)) {
setClauses.push(`${key} = ?`);
params.push(value);
}
setClauses.push("updated_at = ?");
params.push(new Date().toISOString());
params.push(id);
const rows = await db.query(
`UPDATE users SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
params,
);
logger.info(`updated user ${id}`);
res.json(rows[0]);
}
export async function deleteUserHandler(
req: Request,
res: Response,
): Promise<void> {
const { id } = req.params;
if (!id) {
res.status(400).json({ error: "missing id" });
return;
}
const existing = await db.query("SELECT id FROM users WHERE id = ?", [id]);
if (existing.length === 0) {
res.status(404).json({ error: "user not found" });
return;
}
await db.query("DELETE FROM users WHERE id = ?", [id]);
logger.info(`deleted user ${id}`);
res.status(204).send();
}
export async function changeRoleHandler(
req: Request,
res: Response,
): Promise<void> {
const { id } = req.params;
const { role } = req.body as { role: UserRole };
if (!["admin", "member", "guest"].includes(role)) {
res.status(400).json({ error: "invalid role" });
return;
}
const existing = await db.query("SELECT id, role FROM users WHERE id = ?", [id]);
if (existing.length === 0) {
res.status(404).json({ error: "user not found" });
return;
}
const previousRole = (existing[0] as { role: string }).role;
await db.query(
"UPDATE users SET role = ?, updated_at = ? WHERE id = ?",
[role, new Date().toISOString(), id],
);
logger.info(`changed role for user ${id}: ${previousRole}${role}`);
res.json({ id, role });
}
export async function deactivateUserHandler(
req: Request,
res: Response,
): Promise<void> {
const { id } = req.params;
const existing = await db.query(
"SELECT id, is_active FROM users WHERE id = ?",
[id],
);
if (existing.length === 0) {
res.status(404).json({ error: "user not found" });
return;
}
if (!(existing[0] as { is_active: boolean }).is_active) {
res.status(409).json({ error: "user already inactive" });
return;
}
await db.query(
"UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?",
[new Date().toISOString(), id],
);
logger.info(`deactivated user ${id}`);
res.json({ id, isActive: false });
}
export async function getUsersByRoleHandler(
req: Request,
res: Response,
): Promise<void> {
const { role } = req.params;
if (!["admin", "member", "guest"].includes(role)) {
res.status(400).json({ error: "invalid role" });
return;
}
const rows = await db.query(
"SELECT * FROM users WHERE role = ? AND is_active = 1 ORDER BY name ASC",
[role],
);
logger.info(`fetched ${rows.length} users with role=${role}`);
res.json({ role, users: rows });
}
export async function searchUsersHandler(
req: Request,
res: Response,
): Promise<void> {
const { q } = req.query as { q?: string };
if (!q || q.trim().length < 2) {
res.status(400).json({ error: "query must be at least 2 characters" });
return;
}
const pattern = `%${q.trim()}%`;
const rows = await db.query(
"SELECT id, name, email, role FROM users WHERE (name LIKE ? OR email LIKE ?) AND is_active = 1 LIMIT 50",
[pattern, pattern],
);
logger.info(`search "${q}" returned ${rows.length} users`);
res.json({ query: q, results: rows });
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论