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
8 changes: 8 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ NEXT_PUBLIC_ONBOARD_CODE=
# Features (optional)
# NEXT_PUBLIC_MAPBOX_TOKEN=

# Governance red-dot via gaia notification-service (optional)
# When enabled, the browse sidebar polls /api/pending-votes (Redis-backed, driven
# by webhooks from gaia) instead of rescanning all editor spaces each tick.
# Requires UPSTASH_REDIS_REST_URL/TOKEN to be set and the webhook URL registered
# in gaia's app_webhooks table with the matching secret.
# NEXT_PUBLIC_USE_NOTIFICATION_SERVICE=true
# GEO_NOTIFICATIONS_WEBHOOK_SECRET= # HMAC-SHA256 shared secret; generate with `openssl rand -hex 32`

# Build (optional)
# DISABLE_REACT_COMPILER=1
# DISABLE_SENTRY=1
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/activity/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function GET(request: Request) {
editorOf: [],
memberOf: [],
documentationImage: null,
pendingVoteProposalIds: [],
personalSpaceId: null,
};

Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/explore/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export async function GET(request: Request) {
editorOf: [],
memberOf: [],
documentationImage: null,
pendingVoteProposalIds: [],
personalSpaceId: null,
};
}
Expand Down
97 changes: 97 additions & 0 deletions apps/web/app/api/geo-notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Either, Schema } from 'effect';
import { NextResponse } from 'next/server';

import { addPendingVote, markNotificationSeen, removePendingVote } from '~/core/notifications/pending-votes-store';
import { verifyGeoSignature } from '~/core/notifications/verify-signature';
import {
RedDotWebhookPayloadSchema,
type RedDotWebhookPayload,
} from '~/core/notifications/webhook-payload-schema';

/**
* Webhook receiver for the gaia notification-service.
*
* Registration: gaia's `app_webhooks` table holds the URL + shared secret.
* The service fans out one POST per (editor, event); we update a per-user
* pending-vote set in Redis that the sidebar reads via /api/pending-votes.
*
* Responses:
* - 200: accepted (including unhandled event types, which we intentionally
* drop so the delivery worker doesn't retry)
* - 401: missing or bad signature
* - 500: misconfigured secret (never reached in prod if env is set)
*/
export async function POST(request: Request) {
const secret = process.env.GEO_NOTIFICATIONS_WEBHOOK_SECRET;
if (!secret) {
console.error('[geo-notifications] GEO_NOTIFICATIONS_WEBHOOK_SECRET not set');
return new NextResponse('webhook secret not configured', { status: 500 });
}

// Read the raw body once — HMAC must verify the exact bytes sent, not a re-serialized JSON.
const rawBody = await request.text();

const valid = verifyGeoSignature({
rawBody,
signatureHeader: request.headers.get('X-Geo-Signature'),
secret,
});
if (!valid) {
return new NextResponse('invalid signature', { status: 401 });
}

let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
// Signature was valid but body isn't JSON. 2xx so it doesn't get retried; log and move on.
console.error('[geo-notifications] signed body was not valid JSON');
return NextResponse.json({ ok: true });
}

const decoded = Schema.decodeUnknownEither(RedDotWebhookPayloadSchema)(parsed);
if (Either.isLeft(decoded)) {
// Unhandled event_type (bounty_*, proposal_updated, proposal_settings_updated, etc.) —
// acknowledge so the service stops retrying.
return NextResponse.json({ ok: true });
}

const payload = decoded.right;
const firstTime = await markNotificationSeen(payload.idempotency_key);
if (!firstTime) {
return NextResponse.json({ ok: true, deduped: true });
}

try {
await handleEvent(payload);
} catch (error) {
// Redis transient failure: return 5xx so the delivery worker retries per its schedule.
console.error('[geo-notifications] failed to update pending-vote cache', error);
return new NextResponse('transient cache error', { status: 503 });
}

return NextResponse.json({ ok: true });
}

async function handleEvent(payload: RedDotWebhookPayload) {
switch (payload.event_type) {
case 'proposal_created':
await addPendingVote(payload.user_space_id, payload.proposal_id);
return;

case 'proposal_voted':
// The service fans out proposal_voted to every editor in the space. For the red
// dot we only care when the recipient *is* the voter — then this user's pending
// item clears. All other recipients' pending state is unchanged by this vote.
if (payload.voter_id && payload.voter_id === payload.user_space_id) {
await removePendingVote(payload.user_space_id, payload.proposal_id);
}
return;

case 'proposal_executed':
case 'proposal_rejected':
// Proposal is closed → nobody needs to vote on it anymore.
await removePendingVote(payload.user_space_id, payload.proposal_id);
return;
}
}
63 changes: 63 additions & 0 deletions apps/web/app/api/pending-votes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

import { fetchPendingVoteProposalIds } from '~/core/browse/fetch-browse-sidebar-data';
import { resolveMemberSpaceFromWalletSafe } from '~/core/browse/resolve-member-space-from-wallet';
import { WALLET_ADDRESS } from '~/core/cookie';
import { fetchEditorSpaceIds } from '~/core/io/subgraph/fetch-editor-space-ids';
import {
getCachedWalletSpace,
getPendingVotes,
seedPendingVotes,
setCachedWalletSpace,
} from '~/core/notifications/pending-votes-store';

/**
* Lightweight endpoint the browse sidebar polls for the red-dot state.
*
* Fast path: Redis SMEMBERS on pending_votes:{user_space_id}. Single-digit ms.
*
* Fallback: if the cache has no entry (cold start or TTL'd out), run the
* authoritative REST scan once and seed the cache. Subsequent polls hit
* the fast path until webhooks or TTL expire the key again.
*
* Never accepts a userSpaceId from the caller — the caller can only see
* their own state, resolved from the WALLET_ADDRESS cookie.
*/
export async function GET() {
const walletAddress = (await cookies()).get(WALLET_ADDRESS)?.value;
if (!walletAddress) {
return NextResponse.json({ proposalIds: [] });
}

const userSpaceId = await resolveUserSpaceId(walletAddress);
if (!userSpaceId) {
return NextResponse.json({ proposalIds: [] });
}

const cached = await getPendingVotes(userSpaceId);
if (cached.hit) {
return NextResponse.json({ proposalIds: cached.proposalIds });
}

// Cold cache: rebuild from authoritative source, then seed Redis so subsequent polls hit fast path.
const editorSpaceIds = await fetchEditorSpaceIds(userSpaceId);
if (editorSpaceIds.length === 0) {
await seedPendingVotes(userSpaceId, []);
return NextResponse.json({ proposalIds: [] });
}

const proposalIds = await fetchPendingVoteProposalIds(userSpaceId, editorSpaceIds);
await seedPendingVotes(userSpaceId, proposalIds);
return NextResponse.json({ proposalIds });
}

async function resolveUserSpaceId(walletAddress: string): Promise<string | null> {
const cached = await getCachedWalletSpace(walletAddress);
if (cached !== null) {
return cached === '' ? null : cached;
}
const resolved = await resolveMemberSpaceFromWalletSafe(walletAddress);
await setCachedWalletSpace(walletAddress, resolved);
return resolved;
}
1 change: 1 addition & 0 deletions apps/web/app/explore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default async function ExploreRoutePage() {
editorOf: [],
memberOf: [],
documentationImage: null,
pendingVoteProposalIds: [],
personalSpaceId: null,
};
}
Expand Down
22 changes: 20 additions & 2 deletions apps/web/app/home/accept-or-reject-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

import { useSmartAccount } from '~/core/hooks/use-smart-account';
import { useVote } from '~/core/hooks/use-vote';
Expand All @@ -11,6 +12,7 @@ import { SmallButton } from '~/design-system/button';
import { Pending } from '~/design-system/pending';

import { Execute } from '~/partials/active-proposal/execute';
import { useAddOptimisticVote, useConfirmVote, useRemoveOptimisticVote } from '~/partials/governance/optimistic-voted-atom';

interface Props {
spaceId: string;
Expand All @@ -30,6 +32,8 @@ export function AcceptOrRejectEditor({
userVote,
proposalId,
}: Props) {
const router = useRouter();

const { vote, status: voteStatus } = useVote({
spaceId,
proposalId,
Expand All @@ -43,15 +47,29 @@ export function AcceptOrRejectEditor({
const isPendingRejection = hasRejected && voteStatus === 'pending';

const { smartAccount } = useSmartAccount();
const addOptimisticVote = useAddOptimisticVote();
const removeOptimisticVote = useRemoveOptimisticVote();
const confirmVote = useConfirmVote();

const onVoteSuccess = () => {
confirmVote(proposalId);
router.refresh();
};

const onVoteError = () => {
removeOptimisticVote(proposalId);
};

const onApprove = () => {
setHasApproved(true);
vote('ACCEPT');
addOptimisticVote(proposalId);
vote('ACCEPT', { onSuccess: onVoteSuccess, onError: onVoteError });
};

const onReject = () => {
setHasRejected(true);
vote('REJECT');
addOptimisticVote(proposalId);
vote('REJECT', { onSuccess: onVoteSuccess, onError: onVoteError });
};

// Terminal / post-vote states on the proposal must win over "You accepted" so we match space
Expand Down
22 changes: 20 additions & 2 deletions apps/web/app/home/accept-or-reject-member.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';

import cx from 'classnames';

Expand All @@ -23,6 +24,7 @@ import { Pending } from '~/design-system/pending';
import { PrefetchLink as Link } from '~/design-system/prefetch-link';

import { Execute } from '~/partials/active-proposal/execute';
import { useAddOptimisticVote, useConfirmVote, useRemoveOptimisticVote } from '~/partials/governance/optimistic-voted-atom';

interface Props {
spaceId: string;
Expand Down Expand Up @@ -65,6 +67,8 @@ export function AcceptOrRejectMember({
}: Props) {
const [selectedVote, setSelectedVote] = useState<'ACCEPT' | 'REJECT' | null>(null);

const router = useRouter();

const { vote, status: voteStatus } = useVote({
spaceId,
proposalId,
Expand All @@ -76,15 +80,29 @@ export function AcceptOrRejectMember({
const isPendingRejection = selectedVote === 'REJECT' && voteStatus === 'pending';

const { smartAccount } = useSmartAccount();
const addOptimisticVote = useAddOptimisticVote();
const removeOptimisticVote = useRemoveOptimisticVote();
const confirmVote = useConfirmVote();

const onVoteSuccess = () => {
confirmVote(proposalId);
router.refresh();
};

const onVoteError = () => {
removeOptimisticVote(proposalId);
};

const onApprove = () => {
setSelectedVote('ACCEPT');
vote('ACCEPT');
addOptimisticVote(proposalId);
vote('ACCEPT', { onSuccess: onVoteSuccess, onError: onVoteError });
};

const onReject = () => {
setSelectedVote('REJECT');
vote('REJECT');
addOptimisticVote(proposalId);
vote('REJECT', { onSuccess: onVoteSuccess, onError: onVoteError });
};

const { hours, minutes } = getProposalTimeRemaining(endTime);
Expand Down
7 changes: 2 additions & 5 deletions apps/web/app/home/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ export async function Component({
myProposalSpaceOptions,
myProposalSpaceIds,
}: Props) {
const listKey = `${governanceTab}-${governanceFilters.spaceId}-${governanceFilters.category}-${governanceFilters.status}-${proposalType}-${connectedAddress}`;

return (
<>
<div className="mx-auto w-full max-w-[880px]">
Expand All @@ -60,7 +58,6 @@ export async function Component({
<p className="text-body text-grey-04">Sign in to see your proposals.</p>
) : governanceTab === 'my' && connectedSpaceId ? (
<React.Suspense
key={listKey}
fallback={
<div className="space-y-2">
<LoadingSkeleton />
Expand All @@ -81,7 +78,6 @@ export async function Component({
</React.Suspense>
) : (
<React.Suspense
key={listKey}
fallback={
<div className="space-y-2">
<LoadingSkeleton />
Expand Down Expand Up @@ -135,7 +131,7 @@ async function PendingProposals({
connectedSpaceId,
governanceFilters,
}: PendingProposalsProps) {
const { node, hasMore } = await PendingProposalsPage({
const { node, hasMore, page0OrderKey } = await PendingProposalsPage({
connectedSpaceId,
connectedAddress,
proposalType,
Expand All @@ -152,6 +148,7 @@ async function PendingProposals({
{node}
{hasMore && connectedSpaceId && (
<HomeProposalsInfiniteScroll
key={page0OrderKey}
connectedSpaceId={connectedSpaceId}
connectedAddress={connectedAddress}
proposalType={proposalType}
Expand Down
11 changes: 10 additions & 1 deletion apps/web/app/home/fetch-my-governance-proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,17 @@ export async function getMyGovernanceProposals(opts: {

const votingRows =
status === 'pending' ? allRows.filter(p => p.status === 'PROPOSED') : allRows;
votingRows.sort((a, b) => b.timing.endTime - a.timing.endTime);
const unique = dedupeByProposalId(votingRows);
if (status === 'pending') {
unique.sort((a, b) => {
const aVoted = a.userVote != null;
const bVoted = b.userVote != null;
if (aVoted !== bVoted) return aVoted ? 1 : -1;
return b.timing.endTime - a.timing.endTime;
});
} else {
unique.sort((a, b) => b.timing.endTime - a.timing.endTime);
}
const offset = page * PAGE_SIZE;
const pageSlice = unique.slice(offset, offset + PAGE_SIZE);

Expand Down
Loading
Loading