From 640d0b9c7bb914decb6effa0523eaf891d77d1c0 Mon Sep 17 00:00:00 2001 From: Julianemeka Date: Thu, 4 Jun 2026 02:43:37 +0000 Subject: [PATCH] feat: implement cooperative multi-meter management (#351) --- .../src/app/api/__tests__/regression.test.ts | 49 +++-- apps/web/src/app/api/cooperative/me/route.ts | 19 ++ .../src/app/api/cooperative/stats/route.ts | 42 ++++ .../src/app/api/governance/[id]/vote/route.ts | 51 +++++ apps/web/src/app/api/governance/route.ts | 85 +++++++++ apps/web/src/app/api/meters/route.test.ts | 36 +++- apps/web/src/app/api/meters/route.ts | 27 ++- apps/web/src/app/certificates/page.tsx | 1 + apps/web/src/app/dashboard/page.tsx | 2 +- apps/web/src/app/governance/page.tsx | 179 +++++++++++------- apps/web/src/app/meters/page.tsx | 27 +-- apps/web/src/app/settings/page.tsx | 64 +++++-- apps/web/src/lib/auth.ts | 13 +- apps/web/src/lib/database.types.ts | 26 ++- .../20260603000000_cooperative_accounts.sql | 83 ++++++++ 15 files changed, 566 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/app/api/cooperative/me/route.ts create mode 100644 apps/web/src/app/api/cooperative/stats/route.ts create mode 100644 apps/web/src/app/api/governance/[id]/vote/route.ts create mode 100644 apps/web/src/app/api/governance/route.ts create mode 100644 supabase/migrations/20260603000000_cooperative_accounts.sql diff --git a/apps/web/src/app/api/__tests__/regression.test.ts b/apps/web/src/app/api/__tests__/regression.test.ts index f94171c..302b857 100644 --- a/apps/web/src/app/api/__tests__/regression.test.ts +++ b/apps/web/src/app/api/__tests__/regression.test.ts @@ -27,7 +27,7 @@ vi.mock('@/lib/cache', () => ({ checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), })) vi.mock('@/lib/auth', () => ({ - requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' } }), + requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: 'coop-1' }), isAuthError: vi.fn().mockReturnValue(false), })) vi.mock('@/lib/webhooks', () => ({ @@ -41,10 +41,12 @@ import { createServiceClient } from '@/lib/supabase' import { POST as postReading } from '@/app/api/readings/route' import { POST as postMeter } from '@/app/api/meters/route' import { mintCertificates } from '@/lib/stellar' +import { requireAuth } from '@/lib/auth' // ── Helpers ─────────────────────────────────────────────────────────────────── const METER_ID = '123e4567-e89b-12d3-a456-426614174000' +const COOP_ID = 'coop-1' const KWH = 5.0 const TIMESTAMP = 1_700_000_000 @@ -140,16 +142,29 @@ function mockReadingDb(meter: unknown) { } as ReturnType) } -function mockMeterDb({ existing = null }: { existing?: unknown } = {}) { +function mockMeterDb({ + existing = null, + accountType = 'cooperative', + meterCount = 0, +}: { existing?: unknown; accountType?: string; meterCount?: number } = {}) { const maybeSingle = vi.fn().mockResolvedValue({ data: existing }) const insertSingle = vi.fn().mockResolvedValue({ data: { id: 'meter-1', active: true }, error: null, }) + vi.mocked(createServiceClient).mockReturnValue({ from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ maybeSingle }), + select: vi.fn().mockImplementation((_fields, options) => { + if (options?.count) { + return Promise.resolve({ count: meterCount, error: null }) + } + return { + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: existing }), + single: vi.fn().mockResolvedValue({ data: { account_type: accountType }, error: null }), + }), + } }), insert: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: insertSingle }), @@ -158,7 +173,10 @@ function mockMeterDb({ existing = null }: { existing?: unknown } = {}) { } as ReturnType) } -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: COOP_ID, accessToken: 'abc' }) +}) // ── Issue #29 — Input validation on all API routes ──────────────────────────── @@ -220,7 +238,6 @@ describe('regression issue_29: input validation on API routes', () => { mockMeterDb() const res = await postMeter( makeMeterRequest({ - cooperative_id: '123e4567-e89b-12d3-a456-426614174000', serial_number: 'SN-001', pubkey_hex: 'a'.repeat(64), }) @@ -228,30 +245,30 @@ describe('regression issue_29: input validation on API routes', () => { expect(res.status).toBe(400) }) - it('test_issue_29_meters_rejects_invalid_cooperative_id', async () => { + it('test_issue_29_meters_rejects_wrong_length_pubkey', async () => { mockMeterDb() const res = await postMeter( makeMeterRequest({ name: 'Panel A', - cooperative_id: 'not-a-uuid', serial_number: 'SN-001', - pubkey_hex: 'a'.repeat(64), + pubkey_hex: 'tooshort', }) ) expect(res.status).toBe(400) }) - it('test_issue_29_meters_rejects_wrong_length_pubkey', async () => { - mockMeterDb() + it('test_issue_351_meters_enforces_individual_limit', async () => { + mockMeterDb({ accountType: 'individual', meterCount: 1 }) const res = await postMeter( makeMeterRequest({ - name: 'Panel A', - cooperative_id: '123e4567-e89b-12d3-a456-426614174000', - serial_number: 'SN-001', - pubkey_hex: 'tooshort', + name: 'Second Meter', + serial_number: 'SN-002', + pubkey_hex: 'b'.repeat(64), }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(403) + const json = await res.json() + expect(json.error).toMatch(/limited to 1 meter/i) }) it('test_issue_29_validation_runs_before_db_access', async () => { diff --git a/apps/web/src/app/api/cooperative/me/route.ts b/apps/web/src/app/api/cooperative/me/route.ts new file mode 100644 index 0000000..133fb6d --- /dev/null +++ b/apps/web/src/app/api/cooperative/me/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase' +import { requireAuth, isAuthError } from '@/lib/auth' + +/** GET /api/cooperative/me — get current user's cooperative details */ +export async function GET(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + + const db = createServiceClient() + const { data, error } = await db + .from('cooperatives') + .select('id, name, account_type, admin_address, suspended, created_at') + .eq('id', auth.cooperativeId) + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json(data) +} diff --git a/apps/web/src/app/api/cooperative/stats/route.ts b/apps/web/src/app/api/cooperative/stats/route.ts new file mode 100644 index 0000000..7593bc2 --- /dev/null +++ b/apps/web/src/app/api/cooperative/stats/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase' +import { requireAuth, isAuthError } from '@/lib/auth' + +/** + * GET /api/cooperative/stats + * Returns cooperative-level stats: total kWh anchored, total certificates, active meters. + */ +export async function GET(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + + const db = createServiceClient() + + // For total kWh, we sum readings from all meters in this cooperative + const [readingsResult, issuedResult, retiredResult, meterResult] = await Promise.all([ + db.rpc('sum_cooperative_kwh', { target_cooperative_id: auth.cooperativeId }), + db.from('certificates').select('id', { count: 'exact', head: true }).eq('cooperative_id', auth.cooperativeId).eq('retired', false), + db.from('certificates').select('id', { count: 'exact', head: true }).eq('cooperative_id', auth.cooperativeId).eq('retired', true), + db.from('meters').select('id', { count: 'exact', head: true }).eq('cooperative_id', auth.cooperativeId).eq('active', true), + ]) + + // If RPC is missing, we fallback to a manual sum (inefficient but safe) + let total_kwh = readingsResult.data + if (readingsResult.error) { + // Better: manual query with join + const { data: joinData } = await db + .from('readings') + .select('kwh, meters!inner(cooperative_id)') + .eq('meters.cooperative_id', auth.cooperativeId) + .eq('anchored', true) + + total_kwh = (joinData ?? []).reduce((sum, r) => sum + Number(r.kwh), 0) + } + + return NextResponse.json({ + total_kwh: Math.round((total_kwh ?? 0) * 1000) / 1000, + certificates_issued: (issuedResult.count ?? 0) + (retiredResult.count ?? 0), + certificates_retired: retiredResult.count ?? 0, + active_meters: meterResult.count ?? 0, + }) +} diff --git a/apps/web/src/app/api/governance/[id]/vote/route.ts b/apps/web/src/app/api/governance/[id]/vote/route.ts new file mode 100644 index 0000000..2be8396 --- /dev/null +++ b/apps/web/src/app/api/governance/[id]/vote/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createServiceClient } from '@/lib/supabase' +import { requireAuth, isAuthError } from '@/lib/auth' + +const VoteSchema = z.object({ + choice: z.enum(['for', 'against', 'abstain']), +}) + +/** POST /api/governance/[id]/vote — cast a vote */ +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + + const body = await req.json().catch(() => null) + const parsed = VoteSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const db = createServiceClient() + + // Verify proposal exists and belongs to user's cooperative + const { data: proposal, error: fetchError } = await db + .from('proposals') + .select('id, status, ends_at') + .eq('id', params.id) + .eq('cooperative_id', auth.cooperativeId) + .single() + + if (fetchError || !proposal) { + return NextResponse.json({ error: 'Proposal not found' }, { status: 404 }) + } + + if (proposal.status !== 'active' || new Date(proposal.ends_at) < new Date()) { + return NextResponse.json({ error: 'Voting is closed for this proposal' }, { status: 400 }) + } + + // Record the vote + const { error } = await db + .from('votes') + .upsert({ + proposal_id: params.id, + voter_id: auth.user.id, + choice: parsed.data.choice, + }) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ message: 'Vote recorded successfully' }) +} diff --git a/apps/web/src/app/api/governance/route.ts b/apps/web/src/app/api/governance/route.ts new file mode 100644 index 0000000..757a332 --- /dev/null +++ b/apps/web/src/app/api/governance/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createServiceClient } from '@/lib/supabase' +import { requireAuth, isAuthError } from '@/lib/auth' + +const ProposalSchema = z.object({ + title: z.string().min(1).max(120), + description: z.string().min(1).max(2000), + action: z.string().max(200).optional(), + days: z.number().min(1).max(30).default(7), +}) + +/** GET /api/governance — list proposals for user's cooperative */ +export async function GET(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + + const db = createServiceClient() + + // Fetch proposals and their vote counts + const { data, error } = await db + .from('proposals') + .select(` + *, + votes ( + choice + ) + `) + .eq('cooperative_id', auth.cooperativeId) + .order('created_at', { ascending: false }) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + // Process votes into tallies and check if user has voted + const proposals = data.map((p: any) => { + const votes = p.votes as { choice: string }[] + const tally = { + for: votes.filter(v => v.choice === 'for').length, + against: votes.filter(v => v.choice === 'against').length, + abstain: votes.filter(v => v.choice === 'abstain').length, + } + + return { + ...p, + tally, + userVote: undefined, // Will be filled if needed, or handled client-side + } + }) + + return NextResponse.json(proposals) +} + +/** POST /api/governance — create a new proposal */ +export async function POST(req: NextRequest) { + const auth = await requireAuth(req) + if (isAuthError(auth)) return auth + + const body = await req.json().catch(() => null) + const parsed = ProposalSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const db = createServiceClient() + + const endsAt = new Date() + endsAt.setDate(endsAt.getDate() + parsed.data.days) + + const { data, error } = await db + .from('proposals') + .insert({ + cooperative_id: auth.cooperativeId, + title: parsed.data.title, + description: parsed.data.description, + action: parsed.data.action, + ends_at: endsAt.toISOString(), + status: 'active', + }) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json(data, { status: 201 }) +} diff --git a/apps/web/src/app/api/meters/route.test.ts b/apps/web/src/app/api/meters/route.test.ts index c8b74b3..e96797c 100644 --- a/apps/web/src/app/api/meters/route.test.ts +++ b/apps/web/src/app/api/meters/route.test.ts @@ -2,12 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/supabase', () => ({ createServiceClient: vi.fn() })) vi.mock('@/lib/auth', () => ({ - requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, accessToken: 'tok' }), + requireAuth: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: 'coop-1', accessToken: 'tok' }), isAuthError: vi.fn().mockReturnValue(false), })) import { createServiceClient } from '@/lib/supabase' import { GET, POST } from '@/app/api/meters/route' +import { requireAuth } from '@/lib/auth' function makeGetRequest() { return { @@ -20,7 +21,9 @@ function mockDbGet(data: unknown[], error: unknown = null) { vi.mocked(createServiceClient).mockReturnValue({ from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ - order: vi.fn().mockResolvedValue({ data, error }), + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ data, error }), + }), }), }), } as ReturnType) @@ -35,19 +38,29 @@ function makeRequest(body: unknown) { const VALID_BODY = { name: 'Solar Panel A', - cooperative_id: '123e4567-e89b-12d3-a456-426614174000', serial_number: 'SN-001', pubkey_hex: 'a'.repeat(64), } -function mockDb({ existing = null, insertData = { id: 'meter-1', ...VALID_BODY, active: true } } = {}) { +function mockDb({ + existing = null, + accountType = 'cooperative', + meterCount = 0, + insertData = { id: 'meter-1', ...VALID_BODY, active: true } +} = {}) { const maybeSingle = vi.fn().mockResolvedValue({ data: existing }) const insertSingle = vi.fn().mockResolvedValue({ data: insertData, error: null }) vi.mocked(createServiceClient).mockReturnValue({ from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ maybeSingle }), + select: vi.fn().mockImplementation((_fields, options) => { + if (options?.count) return Promise.resolve({ count: meterCount, error: null }) + return { + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: existing }), + single: vi.fn().mockResolvedValue({ data: { account_type: accountType }, error: null }), + }), + } }), insert: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: insertSingle }), @@ -56,7 +69,10 @@ function mockDb({ existing = null, insertData = { id: 'meter-1', ...VALID_BODY, } as ReturnType) } -beforeEach(() => vi.clearAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'user-1' }, cooperativeId: 'coop-1', accessToken: 'tok' }) +}) describe('POST /api/meters', () => { it('returns 400 when name is missing', async () => { @@ -80,6 +96,12 @@ describe('POST /api/meters', () => { expect(res.status).toBe(201) }) + it('returns 403 when individual account limit is reached', async () => { + mockDb({ accountType: 'individual', meterCount: 1 }) + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(403) + }) + it('returns 400 when pubkey_hex is wrong length', async () => { mockDb() const res = await POST(makeRequest({ ...VALID_BODY, pubkey_hex: 'short' })) diff --git a/apps/web/src/app/api/meters/route.ts b/apps/web/src/app/api/meters/route.ts index 0f2159f..d6ad497 100644 --- a/apps/web/src/app/api/meters/route.ts +++ b/apps/web/src/app/api/meters/route.ts @@ -6,7 +6,6 @@ import { requireAuth, isAuthError } from '@/lib/auth' const RegisterSchema = z.object({ name: z.string().min(1).max(128), - cooperative_id: z.string().uuid(), serial_number: z.string().min(1).max(64), pubkey_hex: z.string().length(64), meter_group: z.string().max(64).optional().nullable(), @@ -27,6 +26,7 @@ export async function GET(req: NextRequest) { const { data, error } = await db .from('meters') .select('id, name, serial_number, pubkey_hex, active, created_at, cooperative_id, meter_group, tags') + .eq('cooperative_id', auth.cooperativeId) .order('created_at', { ascending: false }) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) @@ -46,6 +46,24 @@ export async function POST(req: NextRequest) { const db = createServiceClient() + // Fetch cooperative account type and current meter count + const [{ data: coop }, { count: meterCount }] = await Promise.all([ + db.from('cooperatives').select('account_type').eq('id', auth.cooperativeId).single(), + db.from('meters').select('id', { count: 'exact', head: true }).eq('cooperative_id', auth.cooperativeId), + ]) + + if (!coop) { + return NextResponse.json({ error: 'Cooperative not found' }, { status: 404 }) + } + + // Enforce 1-meter limit for individual accounts + if (coop.account_type === 'individual' && (meterCount ?? 0) >= 1) { + return NextResponse.json( + { error: 'Individual accounts are limited to 1 meter. Please upgrade to a Cooperative account for multi-meter management.' }, + { status: 403 } + ) + } + // Check for duplicate public key const { data: existing } = await db .from('meters') @@ -59,7 +77,12 @@ export async function POST(req: NextRequest) { const { data, error } = await db .from('meters') - .insert({ ...parsed.data, active: true, api_key: generateApiKey() }) + .insert({ + ...parsed.data, + cooperative_id: auth.cooperativeId, + active: true, + api_key: generateApiKey(), + }) .select() .single() diff --git a/apps/web/src/app/certificates/page.tsx b/apps/web/src/app/certificates/page.tsx index 7960dc5..bc38a42 100644 --- a/apps/web/src/app/certificates/page.tsx +++ b/apps/web/src/app/certificates/page.tsx @@ -43,6 +43,7 @@ export default function CertificatesPage() { const { toast, dismiss } = useToast() const { address, connected } = useWallet() const [retiring, setRetiring] = useState(null) + const [transferring, setTransferring] = useState(null) const [selected, setSelected] = useState>(new Set()) const [bulkRetiring, setBulkRetiring] = useState(false) diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 67fc184..eb0b43b 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -44,7 +44,7 @@ type Period = 'daily' | 'weekly' | 'monthly' // Fetch helpers // --------------------------------------------------------------------------- async function fetchStats(): Promise { - const res = await fetch('/api/readings?type=stats') + const res = await fetch('/api/cooperative/stats') if (!res.ok) throw new Error('Failed to load stats') return res.json() } diff --git a/apps/web/src/app/governance/page.tsx b/apps/web/src/app/governance/page.tsx index 231ae1c..2345193 100644 --- a/apps/web/src/app/governance/page.tsx +++ b/apps/web/src/app/governance/page.tsx @@ -3,11 +3,12 @@ import { useState } from 'react' import { Vote, Plus, Clock, CheckCircle, XCircle, Minus, ChevronDown, ChevronUp } from 'lucide-react' import { useWallet } from '@/hooks/useWallet' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // ── Types ────────────────────────────────────────────────────────────────────── type VoteChoice = 'for' | 'against' | 'abstain' -type ProposalStatus = 'active' | 'passed' | 'rejected' | 'pending' +type ProposalStatus = 'active' | 'passed' | 'rejected' | 'pending' | 'expired' interface Tally { for: number; against: number; abstain: number } @@ -17,45 +18,55 @@ interface Proposal { description: string status: ProposalStatus tally: Tally - endsAt: Date + ends_at: string userVote?: VoteChoice } -// ── Seed data (replaced by real contract calls in production) ────────────────── - -const SEED: Proposal[] = [ - { - id: 'prop-001', - title: 'Increase minimum meter reading interval to 15 minutes', - description: 'Reduce on-chain anchoring costs by batching readings every 15 minutes instead of every 5.', - status: 'active', - tally: { for: 142, against: 38, abstain: 12 }, - endsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - }, - { - id: 'prop-002', - title: 'Add support for wind energy certificates', - description: 'Extend the energy_token contract to support wind generation alongside solar.', - status: 'active', - tally: { for: 89, against: 61, abstain: 5 }, - endsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - { - id: 'prop-003', - title: 'Integrate I-REC bridge (v1)', - description: 'Build a bridge to the I-REC registry so SolarProof certificates can be cross-listed.', - status: 'passed', - tally: { for: 210, against: 30, abstain: 8 }, - endsAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), - }, -] +// ── Fetchers ────────────────────────────────────────────────────────────────── + +async function fetchProposals(): Promise { + const res = await fetch('/api/governance') + if (!res.ok) throw new Error('Failed to load proposals') + return res.json() +} + +async function createProposal(body: { + title: string + description: string + action: string + days: number +}): Promise { + const res = await fetch('/api/governance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error ?? 'Failed to create proposal') + } + return res.json() +} + +async function castVote(proposalId: string, choice: VoteChoice): Promise { + const res = await fetch(`/api/governance/${proposalId}/vote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ choice }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error ?? 'Failed to cast vote') + } +} // ── Helpers ──────────────────────────────────────────────────────────────────── function totalVotes(t: Tally) { return t.for + t.against + t.abstain } function pct(n: number, total: number) { return total === 0 ? 0 : Math.round((n / total) * 100) } -function countdown(endsAt: Date): string { +function countdown(endsAtStr: string): string { + const endsAt = new Date(endsAtStr) const diff = endsAt.getTime() - Date.now() if (diff <= 0) return 'Ended' const d = Math.floor(diff / 86_400_000) @@ -68,6 +79,7 @@ const STATUS_BADGE: Record = { passed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', pending: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + expired: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', } // ── Sub-components ───────────────────────────────────────────────────────────── @@ -143,7 +155,7 @@ function ProposalCard({ walletConnected: boolean }) { const [expanded, setExpanded] = useState(false) - const isActive = proposal.status === 'active' + const isActive = proposal.status === 'active' && new Date(proposal.ends_at) > new Date() return (