Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions apps/dashboard/app/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
} from "@guildpass/integration-client";
import type { ActivityChange } from "@guildpass/integration-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useGuild } from "@/lib/guild/GuildProvider";`nimport { usePathname, useRouter, useSearchParams } from "next/navigation";`nimport type { ActivitySortOrder } from "@/lib/activity/query";
import { useGuild } from "@/lib/guild/GuildProvider";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { ActivitySortOrder } from "@/lib/activity/query";

const TYPE_ICON: Record<ActivityEventType, string> = {
"member.joined": "👤",
Expand Down Expand Up @@ -68,6 +70,7 @@ const SOURCE_FILTERS: { label: string; value: ActivityEventSource | "" }[] = [
{ label: "Dashboard", value: "dashboard" },
{ label: "Webhook", value: "webhook" },
{ label: "Core API", value: "core_api" },
{ label: "Reconciliation", value: "reconciliation" },
];

const SEVERITY_FILTERS: { label: string; value: ActivityEventSeverity | "" }[] = [
Expand All @@ -94,7 +97,17 @@ function readLimit(value: string | null): number {
return PAGE_SIZE_OPTIONS.includes(parsed as (typeof PAGE_SIZE_OPTIONS)[number]) ? parsed : 10;
}
export default function ActivityPage() {
const { guildId, guild } = useGuild();`n const router = useRouter();`n const pathname = usePathname();`n const searchParams = useSearchParams();`n const [type, setType] = useState<ActivityEventType | "">(() => (searchParams.get("type") as ActivityEventType | null) ?? "");`n const [source, setSource] = useState<ActivityEventSource | "">(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");`n const [severity, setSeverity] = useState<ActivityEventSeverity | "">(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");`n const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");`n const [from, setFrom] = useState(() => searchParams.get("from") ?? "");`n const [sort, setSort] = useState<ActivitySortOrder>(() => readSort(searchParams.get("sort")));`n const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit")));
const { guildId, guild } = useGuild();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [type, setType] = useState<ActivityEventType | "">(() => (searchParams.get("type") as ActivityEventType | null) ?? "");
const [source, setSource] = useState<ActivityEventSource | "">(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");
const [severity, setSeverity] = useState<ActivityEventSeverity | "">(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");
const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");
const [from, setFrom] = useState(() => searchParams.get("from") ?? "");
const [sort, setSort] = useState<ActivitySortOrder>(() => readSort(searchParams.get("sort")));
const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit")));
const { intervalMs } = getActivityRefreshConfig();
const updateActivityQuery = useCallback(
(updates: {
Expand All @@ -108,7 +121,12 @@ export default function ActivityPage() {
}) => {
const next = new URLSearchParams(searchParams.toString());
const setOrDelete = (key: string, value: string) => {
value.trim() ? next.set(key, value.trim()) : next.delete(key);
const trimmed = value.trim();
if (trimmed) {
next.set(key, trimmed);
} else {
next.delete(key);
}
};

if (updates.type !== undefined) setOrDelete("type", updates.type);
Expand All @@ -117,10 +135,18 @@ export default function ActivityPage() {
if (updates.actor !== undefined) setOrDelete("actor", updates.actor);
if (updates.from !== undefined) setOrDelete("from", updates.from);
if (updates.sort !== undefined) {
updates.sort === "newest" ? next.delete("sort") : next.set("sort", updates.sort);
if (updates.sort === "newest") {
next.delete("sort");
} else {
next.set("sort", updates.sort);
}
}
if (updates.limit !== undefined) {
updates.limit === 10 ? next.delete("limit") : next.set("limit", String(updates.limit));
if (updates.limit === 10) {
next.delete("limit");
} else {
next.set("limit", String(updates.limit));
}
}

const query = next.toString();
Expand Down Expand Up @@ -170,7 +196,8 @@ export default function ActivityPage() {
source: source || undefined,
severity: severity || undefined,
actor: actor.trim() || undefined,
from: fromIso,`n sort,
from: fromIso,
sort,
autoRefresh: true,
simulate: false,
guildId,
Expand All @@ -183,7 +210,11 @@ export default function ActivityPage() {
setSource("");
setSeverity("");
setActor("");
setFrom("");`n setSort("newest");`n setLimit(10);`n updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });`n };
setFrom("");
setSort("newest");
setLimit(10);
updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });
};

return (
<DashboardLayout
Expand Down Expand Up @@ -365,7 +396,7 @@ export default function ActivityPage() {
>
{formatRelativeTime(activity.timestamp)}
</span>
<span className={`rounded-full px-2 py-1 text-xs ${activity.source === "webhook" ? "bg-indigo-50 text-indigo-700" : "bg-slate-100 text-slate-700"}`}>
<span className={`rounded-full px-2 py-1 text-xs ${activity.source === "webhook" ? "bg-indigo-50 text-indigo-700" : activity.source === "reconciliation" ? "bg-amber-50 text-amber-700" : "bg-slate-100 text-slate-700"}`}>
{activity.source}
</span>
<span className={`rounded-full px-2 py-1 text-xs ${activity.severity === "error" || activity.severity === "critical" ? "bg-red-50 text-red-700" : "bg-slate-100 text-slate-700"}`}>
Expand Down
12 changes: 6 additions & 6 deletions apps/dashboard/app/api/admin/reconcile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ import { getActiveGuildId } from "@/lib/guild-context";
// ── Counting strategies ───────────────────────────────────────────────────────
//
// Same defaults as the CLI script. In production, these should use direct SQL
// queries for performance. For mock mode, they count all entries since members
// and passes are not yet partitioned by guild in the mock data model.
// queries for performance. Member and pass repositories are tenant-scoped, so
// counts are always per guild (see docs/multi-tenancy.md).

async function countMembersForGuild(_guildId: string): Promise<number> {
async function countMembersForGuild(guildId: string): Promise<number> {
const memberRepo = getMemberRepository();
const all = await memberRepo.getAll();
const all = await memberRepo.getAll(guildId);
return all.length;
}

async function countPassesForGuild(_guildId: string): Promise<number> {
async function countPassesForGuild(guildId: string): Promise<number> {
const passRepo = getPassRepository();
const all = await passRepo.getAll();
const all = await passRepo.getAll(guildId);
return all.length;
}

Expand Down
31 changes: 0 additions & 31 deletions apps/dashboard/app/api/guilds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,37 +41,6 @@ export async function GET(): Promise<NextResponse> {
* ⚠️ In production, resolve the session from the request (JWT / cookie)
* instead of using MOCK_SESSION, then assertPermission against it.
*/
export async function POST(request: Request): Promise<NextResponse> {
try {
assertCsrfToken(request);
} catch (err) {
if (err instanceof CsrfError) {
return apiError(err.message, 403);
}
throw err;
}

try {
assertPermission(MOCK_API_SESSION, "guilds:write");
} catch (err) {
if (err instanceof PermissionDeniedError) {
return apiError(err.message, 403);
}
throw err;
}

return handleApiError(async () => {
// TODO: implement guild request: Request): Promise<NextResponse> {
try {
assertCsrfToken(request);
} catch (err) {
if (err instanceof CsrfError) {
return apiError(err.message, 403);
}
throw err;
}

return { message: "Guild created (stub)" };
export async function POST(request: Request): Promise<NextResponse> {
const guard = await requireSessionAndPermission(request, getActiveGuildId(request), "guilds:write");
if (!guard.ok) return guard.response;
Expand Down
117 changes: 117 additions & 0 deletions apps/dashboard/app/api/integrations/reconcile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* POST /api/integrations/reconcile
*
* Admin-gated manual trigger for core-state reconciliation (issue #262):
* pulls an authoritative snapshot from GuildPass core, diffs it against
* local state, and reports (dry-run) or applies (apply) corrections.
*
* Permissions: Requires "settings:write" (admin/owner roles).
*
* Request body:
* { "mode": "dry-run" | "apply" }
*
* Response (200):
* The CoreSyncReport (see lib/reconciliation/core-sync-types.ts). When the
* deployment has no core configured (pure mock mode) or core does not
* implement the snapshot endpoint, the report has `supported: false` with
* a human-readable reason — this is not an error.
*
* Response (400): Invalid or missing mode.
* Response (401): No dashboard session.
* Response (403): Caller lacks settings:write.
*/

import { NextResponse } from "next/server";
import { IntegrationClient } from "@guildpass/integration-client";
import { apiError, apiResponse, apiValidationError, handleApiError } from "@/lib/api-helpers";
import { requireDashboardSession, UnauthorizedError } from "@/lib/auth/server-session";
import { assertPermission, PermissionDeniedError } from "@/lib/permissions";
import { getActiveGuildId } from "@/lib/guild-context";
import { getEnv } from "@/lib/env";
import { reconcileGuildWithCore } from "@/lib/reconciliation/core-sync";
import type { CoreSyncMode, SnapshotClient } from "@/lib/reconciliation/core-sync-types";
import type { ApiFieldError } from "@/lib/api-contracts";

export async function POST(request: Request): Promise<NextResponse> {
// ── Auth guard ──────────────────────────────────────────────────────────
try {
const session = await requireDashboardSession(request);
assertPermission(session, getActiveGuildId(request), "settings:write");
} catch (err) {
if (err instanceof PermissionDeniedError) {
return apiError(err.message, 403);
}
if (err instanceof UnauthorizedError) {
return apiError(err.message, 401);
}
throw err;
}

return handleApiError(async () => {
// ── Parse & validate body ─────────────────────────────────────────────
let body: unknown;
try {
body = await request.json();
} catch {
return apiValidationError("Invalid reconciliation request", [
{ field: "body", message: "Request body must be a JSON object" },
]);
}

const errors = validateBody(body);
if (errors.length > 0) {
return apiValidationError("Invalid reconciliation request", errors);
}

const { mode } = body as { mode: CoreSyncMode };
const guildId = getActiveGuildId(request);

const client = resolveSnapshotClient();
if (!client) {
return apiResponse({
guildId,
mode,
supported: false,
reason:
"No GuildPass core is configured for this deployment " +
"(GUILD_PASS_CORE_URL is unset). Reconciliation needs a core " +
"that serves GET /v1/guilds/:guildId/snapshot.",
changes: [],
totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 },
applied: 0,
summary: "Reconciliation unavailable: no core configured.",
});
}

const report = await reconcileGuildWithCore({ guildId, mode, client });
return apiResponse(report);
});
}

/**
* Pick the snapshot source: test injection first, then a real core client
* when a core URL is configured. Returns null in pure mock mode.
*/
function resolveSnapshotClient(): SnapshotClient | null {
const testClient = (globalThis as Record<string, unknown>).__TEST_INTEGRATION_CLIENT;
if (testClient) return testClient as SnapshotClient;

const env = getEnv();
if (!env.GUILD_PASS_CORE_URL) return null;

return new IntegrationClient({
baseUrl: env.GUILD_PASS_CORE_URL,
apiKey: env.GUILD_PASS_CORE_API_KEY,
});
}

function validateBody(body: unknown): ApiFieldError[] {
if (!body || typeof body !== "object") {
return [{ field: "body", message: "Request body must be a JSON object" }];
}
const { mode } = body as Record<string, unknown>;
if (mode !== "dry-run" && mode !== "apply") {
return [{ field: "mode", message: 'mode must be either "dry-run" or "apply"' }];
}
return [];
}
13 changes: 12 additions & 1 deletion apps/dashboard/app/api/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ export async function GET(request: Request): Promise<NextResponse> {
const query = parseMemberListQuery(request);

if (apiMode === "live") {
// Live mode only supports direct lookups; a bare list request is
// unsupported — reject it before constructing a client we won't use.
if (!wallet && !discordUserId) {
return apiUnsupported(
"members.list",
apiMode,
"Live mode requires a lookup (wallet or discordUserId)"
);
}

const testClient = (globalThis as any).__TEST_INTEGRATION_CLIENT;
let client;

Expand Down Expand Up @@ -191,8 +201,9 @@ export async function PATCH(request: Request): Promise<NextResponse> {

const memberRepository = getMemberRepository();
const guildId = getActiveGuildId(request);
const { version: expectedVersion, ...updateData } = validation.data;
const existing = validation.data.roles ? await memberRepository.getById(guildId, id) : null;
const updated = await memberRepository.update(guildId, id, validation.data, expectedVersion);
const updated = await memberRepository.update(guildId, id, updateData, expectedVersion);
if (!updated) throw new NotFoundError("Member not found.");
const rolesChanged = existing && validation.data.roles && JSON.stringify(existing.roles) !== JSON.stringify(validation.data.roles);
if (rolesChanged) {
Expand Down
24 changes: 2 additions & 22 deletions apps/dashboard/app/api/passes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@/lib/api-helpers";
import { NotFoundError } from "@/lib/api-errors";
import { mockPasses, type Pass } from "@/lib/mock-data";
import { getActiveGuildId } from "@/lib/guild-context";
import { requireSessionAndPermission } from "@/lib/auth/require-permission";
import { getApiMode } from "@/lib/env";
import { getPassRepository } from "@/lib/repositories/factory";
Expand Down Expand Up @@ -37,11 +38,7 @@ export async function GET(

try {
const passRepository = getPassRepository();
<<<<<<< HEAD
return await passRepository.query(query);
=======
return await passRepository.query(getActiveGuildId(request), query);
>>>>>>> main
} catch (error) {
console.error("Error fetching passes:", error);
return getFallbackPasses(request, query);
Expand All @@ -66,15 +63,10 @@ function isPassStatus(value: string | null): value is Pass["status"] {
return value !== null && PASS_STATUSES.includes(value as Pass["status"]);
}

<<<<<<< HEAD
function getFallbackPasses(query: PassListQuery) {
const filtered = filterPasses(mockPasses, query);
=======
function getFallbackPasses(request: Request, query: PassListQuery) {
const guildId = getActiveGuildId(request);
const scoped = mockPasses.filter((pass) => pass.guildId === guildId);
const filtered = filterPasses(scoped, query);
>>>>>>> main
return paginateItems(filtered, query);
}

Expand All @@ -97,11 +89,7 @@ export async function POST(request: Request): Promise<NextResponse> {
}

const passRepository = getPassRepository();
<<<<<<< HEAD
const created = await passRepository.create(validation.data);
=======
const created = await passRepository.create(getActiveGuildId(request), validation.data);
>>>>>>> main
await recordDashboardActivity({
type: "pass.created",
entity: { type: "pass", id: created.id, name: created.name },
Expand Down Expand Up @@ -139,11 +127,7 @@ export async function PATCH(request: Request): Promise<NextResponse> {
}

const passRepository = getPassRepository();
<<<<<<< HEAD
const updated = await passRepository.update(id, validation.data);
=======
const updated = await passRepository.update(getActiveGuildId(request), id, validation.data);
>>>>>>> main
if (!updated) throw new NotFoundError("Pass not found.");
await recordDashboardActivity({
type: "pass.updated",
Expand All @@ -170,14 +154,10 @@ export async function DELETE(request: Request): Promise<NextResponse> {

return handleApiError(async () => {
const passRepository = getPassRepository();
<<<<<<< HEAD
const pass = await passRepository.getById(id);
=======
const guildId = getActiveGuildId(request);
const pass = await passRepository.getById(guildId, id);
>>>>>>> main
if (!pass) throw new NotFoundError("Pass not found.");
const success = await passRepository.delete(id);
const success = await passRepository.delete(guildId, id);
if (!success) throw new NotFoundError("Pass not found.");
await recordDashboardActivity({
type: "pass.deleted",
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ handleApiError,
} from "@/lib/api-helpers";
import { validateLiveModeEnv, getApiMode } from "@/lib/env";
import { IntegrationClient, type VerificationResult } from "@guildpass/integration-client";
import { isValidChecksumAddress, normaliseAddress } from "@/dashboard/lib/address";
import { isValidChecksumAddress, normaliseAddress } from "@/lib/address";

export async function POST(request: Request): Promise<NextResponse> {
return handleApiError(async () => {
Expand Down
Loading
Loading