From 02a338fe7c32482fc641596b51ebb85b4c45aa59 Mon Sep 17 00:00:00 2001 From: jchinedu Date: Sat, 25 Jul 2026 22:47:50 +0100 Subject: [PATCH] Closes #304 --- apps/dashboard/app/activity/page.tsx | 12 +++++- apps/dashboard/app/members/page.tsx | 12 +++++- apps/dashboard/lib/activity/pubsub.ts | 5 +-- apps/dashboard/lib/auth/csrf.ts | 2 +- .../lib/repositories/adapters/durable.ts | 6 +-- apps/dashboard/next.config.mjs | 2 +- apps/dashboard/package.json | 2 +- apps/dashboard/tsconfig.json | 6 ++- .../integration-client/src/http/httpClient.ts | 7 +++- .../src/repositories/types.ts | 38 +++++++++++++++++ packages/integration-client/src/types.ts | 42 +++++++++++++++++++ .../test/circuitBreaker.test.js | 4 +- .../integration-client/test/snapshot.test.js | 4 +- packages/integration-client/tsconfig.json | 3 +- 14 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 packages/integration-client/src/repositories/types.ts diff --git a/apps/dashboard/app/activity/page.tsx b/apps/dashboard/app/activity/page.tsx index 3345e10..7fd68df 100644 --- a/apps/dashboard/app/activity/page.tsx +++ b/apps/dashboard/app/activity/page.tsx @@ -12,7 +12,7 @@ import { type ActivityEventType, } from "@guildpass/integration-client"; import type { ActivityChange } from "@guildpass/integration-client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { useGuild } from "@/lib/guild/GuildProvider"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { ActivitySortOrder } from "@/lib/activity/query"; @@ -97,7 +97,7 @@ function readLimit(value: string | null): number { const parsed = Number(value); return PAGE_SIZE_OPTIONS.includes(parsed as (typeof PAGE_SIZE_OPTIONS)[number]) ? parsed : 10; } -export default function ActivityPage() { +function ActivityPageContent() { const { guildId, guild } = useGuild(); const router = useRouter(); const pathname = usePathname(); @@ -473,3 +473,11 @@ function DiffRow({ change }: { change: ActivityChange }) { ); } + +export default function ActivityPage() { + return ( + Loading activity...}> + + + ); +} diff --git a/apps/dashboard/app/members/page.tsx b/apps/dashboard/app/members/page.tsx index 364a993..dfd0bee 100644 --- a/apps/dashboard/app/members/page.tsx +++ b/apps/dashboard/app/members/page.tsx @@ -16,7 +16,7 @@ import { toMembersCsv } from "@/lib/members-csv"; import type { Member as MockMember } from "@/lib/mock-data"; import { canManageMembers } from "@/lib/permissions"; import type { PaginatedResult } from "@/lib/repositories/types"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useGuild } from "@/lib/guild/GuildProvider"; import { guildFetch } from "@/lib/guild/api"; import { getMembersForGuild } from "@/lib/data/guild-scoped"; @@ -61,7 +61,7 @@ function readPageFilter(value: string | null): number { return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1; } -export default function MembersPage() { +function MembersPageContent() { const session = useSession(); const canWrite = canManageMembers(session, session.activeGuildId); const apiMode = getClientApiMode(); @@ -596,3 +596,11 @@ function useDebouncedValue(value: T, delayMs: number): T { return debounced; } + +export default function MembersPage() { + return ( + Loading members...}> + + + ); +} diff --git a/apps/dashboard/lib/activity/pubsub.ts b/apps/dashboard/lib/activity/pubsub.ts index c9d747e..d8e3a7a 100644 --- a/apps/dashboard/lib/activity/pubsub.ts +++ b/apps/dashboard/lib/activity/pubsub.ts @@ -42,6 +42,7 @@ import type { ActivityEvent } from "./types"; import { getPool } from "../db"; import { getStorageMode, getStorageConfig } from "../env"; +import { PoolClient } from "pg"; // ── Types ──────────────────────────────────────────────────────────────────── export type ActivitySubscriber = (event: ActivityEvent) => void; @@ -106,9 +107,7 @@ class LocalPubSubImpl implements ILocalPubSub { class PostgresPubSubImpl implements ILocalPubSub { private listeners = new Map>(); - private pgListenerClient: Awaited< - ReturnType["connect"]> - > | null = null; + private pgListenerClient: PoolClient | null = null; private listenerRefCount = 0; private connectionError: Error | null = null; diff --git a/apps/dashboard/lib/auth/csrf.ts b/apps/dashboard/lib/auth/csrf.ts index 4e3429a..9bfbd84 100644 --- a/apps/dashboard/lib/auth/csrf.ts +++ b/apps/dashboard/lib/auth/csrf.ts @@ -22,7 +22,7 @@ * – Token comparison is constant-time to prevent timing attacks. */ -import { timingSafeEqual, randomBytes } from "node:crypto"; +import { timingSafeEqual, randomBytes } from "crypto"; // ── Constants ───────────────────────────────────────────────────────────────── diff --git a/apps/dashboard/lib/repositories/adapters/durable.ts b/apps/dashboard/lib/repositories/adapters/durable.ts index b567f6a..b815c33 100644 --- a/apps/dashboard/lib/repositories/adapters/durable.ts +++ b/apps/dashboard/lib/repositories/adapters/durable.ts @@ -229,9 +229,9 @@ function rowToSettings(row: any): DashboardSettings { return settings; } -function generateEventId(): string { - return `evt_${Date.now()}_${crypto.randomBytes(6).toString("hex")}`; -} +// Duplicate generateEventId removed – use implementation defined earlier + + // ── Pass Repository ───────────────────────────────────────────────────────── diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 2f99f19..2bad752 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -42,7 +42,7 @@ const nextConfig = { // Exclude health check from the build output (it's a serverless function) // and ensure it's not statically generated - serverExternalPackages: [], + // serverExternalPackages removed }; export default nextConfig; \ No newline at end of file diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b10e686..4b9baa8 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -10,7 +10,7 @@ "start": "next start", "typecheck": "tsc --noEmit", "lint": "eslint .", - "test": "tsx --test test/**/*.test.ts", + "test": "npm run build -w @guildpass/env && tsx --test test/**/*.test.ts", "test:js": "node --test test/**/*.test.js", "db:migrate": "tsx scripts/migrate.ts", "db:seed": "tsx scripts/seed.ts" diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json index 2be5c1b..920d0f6 100644 --- a/apps/dashboard/tsconfig.json +++ b/apps/dashboard/tsconfig.json @@ -17,7 +17,11 @@ "incremental": true, "plugins": [{"name": "next"}], "baseUrl": ".", - "paths": {"@/*": ["./*"]} + "paths": { + "@/*": ["./*"], + "@guildpass/integration-client/*": ["../../packages/integration-client/*"], + "@guildpass/mock-repositories": ["../../packages/integration-client/mock/mockRepositories"] + } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] diff --git a/packages/integration-client/src/http/httpClient.ts b/packages/integration-client/src/http/httpClient.ts index 0bf768a..7ff4d8e 100644 --- a/packages/integration-client/src/http/httpClient.ts +++ b/packages/integration-client/src/http/httpClient.ts @@ -78,9 +78,14 @@ export class HttpClient { return response; } + // Treat 404 as a non-error response; return it for caller to handle. + if (response.status === 404) { + // Do not record circuit breaker failure for 404. + return response; + } + // Non-OK response: check if we should retry (transient) or fail. if (attempt >= maxAttempts || !this.isTransient(response.status)) { - if (this.breaker) this.breaker.recordFailure(); throw new UpstreamError(response.status, response.statusText); } diff --git a/packages/integration-client/src/repositories/types.ts b/packages/integration-client/src/repositories/types.ts new file mode 100644 index 0000000..3563029 --- /dev/null +++ b/packages/integration-client/src/repositories/types.ts @@ -0,0 +1,38 @@ +// packages/integration-client/src/repositories/types.ts + +/** + * Generic pagination result. + */ +export interface Paginated { + items: T[]; + total: number; + /** Zero‑based index of the first item in this page */ + offset: number; + /** Number of items per page */ + limit: number; +} + +// Base repository signatures for each entity. +export interface PassRepository { + /** List passes – pagination can be added later */ + list(): Promise>; + /** Get a single pass by id */ + get(id: string): Promise; +} + +export interface GuildRepository { + list(): Promise>; + get(id: string): Promise; +} + +export interface MemberRepository { + list(): Promise>; + get(id: string): Promise; +} + +export interface ActivityRepository { + /** Return activity events – same shape as current mock fetchActivity */ + list(): Promise; + /** Generate a mock activity for testing */ + generateMock(): Promise; +} diff --git a/packages/integration-client/src/types.ts b/packages/integration-client/src/types.ts index 9ad7d2d..b9286dd 100644 --- a/packages/integration-client/src/types.ts +++ b/packages/integration-client/src/types.ts @@ -162,3 +162,45 @@ export type ActivityEvent = { */ schemaVersion: number; }; +export interface Pass { + id: string; + guildId: string; + name: string; + description: string; + status: 'active' | 'inactive' | 'draft'; + price?: number; + maxSupply?: number | null; + currentSupply: number; + createdAt: string; +} + +export interface Guild { + id: string; + name: string; + description: string; + memberCount: number; + passCount: number; + createdAt: string; +} + +export interface Member { + id: string; + guildId: string; + wallet: string; + name: string; + status: 'active' | 'inactive' | 'pending'; + roles: string[]; + joinedAt: string; + lastActive: string; + version: number; +} + +export interface Activity { + id: string; + guildId: string; + type: 'pass_created' | 'pass_purchased' | 'member_joined' | 'role_changed' | 'access_granted'; + description: string; + timestamp: string; + actor: string; + changes?: ActivityChange[]; +} diff --git a/packages/integration-client/test/circuitBreaker.test.js b/packages/integration-client/test/circuitBreaker.test.js index 23184ef..c2bd6ac 100644 --- a/packages/integration-client/test/circuitBreaker.test.js +++ b/packages/integration-client/test/circuitBreaker.test.js @@ -81,8 +81,8 @@ describe("HttpClient + circuit breaker (integration)", () => { circuitBreaker: { failureThreshold: 2, cooldownMs: 10000 }, }); - await client.request("http://x"); - await client.request("http://x"); + await client.request("http://x").catch(() => {}); + await client.request("http://x").catch(() => {}); assert.strictEqual(fetchCalls, 2); await assert.rejects( diff --git a/packages/integration-client/test/snapshot.test.js b/packages/integration-client/test/snapshot.test.js index 289ecd9..d61e63f 100644 --- a/packages/integration-client/test/snapshot.test.js +++ b/packages/integration-client/test/snapshot.test.js @@ -58,8 +58,8 @@ describe("IntegrationClient.getGuildSnapshot", () => { assert.strictEqual(result, null); }); - test("throws core: on other non-OK responses", async () => { + test("throws UpstreamError on other non-OK responses", async () => { const client = clientWithFetch(async () => jsonResponse(500, { error: "boom" })); - await assert.rejects(() => client.getGuildSnapshot("guild-1"), /core:500/); + await assert.rejects(() => client.getGuildSnapshot("guild-1"), /Upstream responded with 500/); }); }); diff --git a/packages/integration-client/tsconfig.json b/packages/integration-client/tsconfig.json index 8c9a77e..e561588 100644 --- a/packages/integration-client/tsconfig.json +++ b/packages/integration-client/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "composite": false + "composite": false, + "ignoreDeprecations": "6.0" }, "include": ["src"] }