Skip to content
Closed
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
44 changes: 37 additions & 7 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 @@ -94,7 +96,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 +120,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 +134,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 +195,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 +209,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
16 changes: 16 additions & 0 deletions apps/dashboard/app/api/activity/stream/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
encodeActivityEvent,
getEventsAfterCursor,
subscribeToActivityEvents,
} from "@/lib/activity/stream";
import { activityStorage } from "@/lib/activity/storage";
import { requireSessionAndPermission } from "@/lib/auth/require-permission";
import { getActiveGuildId } from "@/lib/guild-context";

Expand All @@ -18,6 +20,17 @@ export async function GET(request: Request): Promise<Response> {
const guard = await requireSessionAndPermission(request, getActiveGuildId(request), "activity:read");
if (!guard.ok) return guard.response;

// Reconnecting clients identify their last received event via the native
// Last-Event-ID header (automatic EventSource retry) or an explicit query
// param (manual reconnect). Snapshot before subscribing so anything
// published in between arrives through the live subscription instead.
const resumeCursor =
request.headers.get("Last-Event-ID") ??
new URL(request.url).searchParams.get("lastEventId");
const missedEvents = resumeCursor
? getEventsAfterCursor(await activityStorage.getEvents(), resumeCursor)
: [];

let dispose = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
Expand Down Expand Up @@ -73,6 +86,9 @@ export async function GET(request: Request): Promise<Response> {
};

enqueueFrame(READY_FRAME);
for (const missed of missedEvents) {
enqueueFrame(encodeActivityEvent(missed));
}
if (request.signal.aborted) {
onAbort();
} else {
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
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
49 changes: 5 additions & 44 deletions apps/dashboard/app/guilds/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { getClientApiMode } from '@/lib/client-env';
import DashboardLayout from "@/components/DashboardLayout";
import UnsupportedBanner from "@/components/UnsupportedBanner";
import EmptyState from "@/components/EmptyState";
import { mockGuilds, type Guild as MockGuild } from "@/lib/mock-data";
import { useEffect, useState, useRef } from "react";
import { useSession } from "@/lib/hooks/useSession";
Expand Down Expand Up @@ -140,16 +141,6 @@ export default function GuildsPage() {
)}

{listState !== "unsupported" && (
<<<<<<< HEAD
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{guilds.map((guild) => {
const isPending = pendingIds.has(guild.id);
return (
<div key={guild.id} className={`bg-white border border-slate-200 rounded-xl p-6 transition-all ${isPending ? "opacity-50 scale-[0.98] pointer-events-none" : "hover:shadow-md"}`}>
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold text-slate-800">{guild.name}</h3>
{isPending && <span className="text-xs text-slate-400 animate-pulse">updating...</span>}
=======
guilds.length === 0 ? (
<EmptyState
title="No guilds yet"
Expand Down Expand Up @@ -228,41 +219,11 @@ export default function GuildsPage() {
</>
)}
</div>
>>>>>>> main
</div>
<p className="text-slate-600 mb-4">{guild.description}</p>
<div className="flex gap-4 text-sm mb-6">
<div>
<span className="text-slate-500">Members:</span>
<span className="font-semibold text-slate-800 ml-2">{guild.memberCount}</span>
</div>
<div>
<span className="text-slate-500">Passes:</span>
<span className="font-semibold text-slate-800 ml-2">{guild.passCount}</span>
</div>
</div>

{canWrite && (
<div className="flex items-center gap-3 pt-4 border-t border-slate-100">
<button
onClick={() => handleRename(guild.id, guild.name)}
className="text-xs font-medium text-slate-600 hover:text-violet-600 transition-colors"
>
Rename
</button>
<span className="text-slate-300">·</span>
<button
onClick={() => handleDelete(guild.id)}
className="text-xs font-medium text-red-500 hover:text-red-700 transition-colors"
>
Delete
</button>
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
)
)}
</DashboardLayout>
);
Expand Down
Loading
Loading