- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
457 lines
15 KiB
Python
457 lines
15 KiB
Python
"""
|
|
DAO Repository — Database operations for DAO
|
|
Phase 8: DAO Dashboard
|
|
"""
|
|
import uuid
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
import asyncpg
|
|
from models import (
|
|
DaoCreate, DaoUpdate, DaoRead, DaoOverview,
|
|
DaoMember, MemberAdd,
|
|
DaoTreasuryItem, TreasuryUpdate
|
|
)
|
|
|
|
class DaoRepository:
|
|
def __init__(self, db_pool: asyncpg.Pool):
|
|
self.db = db_pool
|
|
|
|
# ========================================================================
|
|
# DAO — CRUD
|
|
# ========================================================================
|
|
|
|
async def create_dao(
|
|
self,
|
|
data: DaoCreate,
|
|
owner_user_id: str
|
|
) -> DaoRead:
|
|
"""Create new DAO and add owner as member"""
|
|
dao_id = uuid.uuid4()
|
|
|
|
async with self.db.acquire() as conn:
|
|
async with conn.transaction():
|
|
# Insert DAO
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO dao (
|
|
id, slug, name, description, microdao_id, owner_user_id,
|
|
governance_model, voting_period_seconds, quorum_percent
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
""",
|
|
dao_id,
|
|
data.slug,
|
|
data.name,
|
|
data.description,
|
|
uuid.UUID(data.microdao_id),
|
|
uuid.UUID(owner_user_id),
|
|
data.governance_model or 'simple',
|
|
data.voting_period_seconds or 604800,
|
|
data.quorum_percent or 20
|
|
)
|
|
|
|
# Add owner as member
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO dao_members (dao_id, user_id, role)
|
|
VALUES ($1, $2, $3)
|
|
""",
|
|
dao_id,
|
|
uuid.UUID(owner_user_id),
|
|
"owner"
|
|
)
|
|
|
|
# Log audit event
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO dao_audit_log (dao_id, actor_user_id, event_type, event_payload)
|
|
VALUES ($1, $2, $3, $4)
|
|
""",
|
|
dao_id,
|
|
uuid.UUID(owner_user_id),
|
|
"dao_created",
|
|
'{"slug": "' + data.slug + '"}'
|
|
)
|
|
|
|
# Get created DAO
|
|
return await self._get_dao_by_id(conn, dao_id)
|
|
|
|
async def update_dao(
|
|
self,
|
|
dao_id: str,
|
|
data: DaoUpdate
|
|
) -> Optional[DaoRead]:
|
|
"""Update DAO"""
|
|
updates = []
|
|
values = []
|
|
param_idx = 1
|
|
|
|
if data.name is not None:
|
|
updates.append(f"name = ${param_idx}")
|
|
values.append(data.name)
|
|
param_idx += 1
|
|
|
|
if data.description is not None:
|
|
updates.append(f"description = ${param_idx}")
|
|
values.append(data.description)
|
|
param_idx += 1
|
|
|
|
if data.governance_model is not None:
|
|
updates.append(f"governance_model = ${param_idx}")
|
|
values.append(data.governance_model)
|
|
param_idx += 1
|
|
|
|
if data.voting_period_seconds is not None:
|
|
updates.append(f"voting_period_seconds = ${param_idx}")
|
|
values.append(data.voting_period_seconds)
|
|
param_idx += 1
|
|
|
|
if data.quorum_percent is not None:
|
|
updates.append(f"quorum_percent = ${param_idx}")
|
|
values.append(data.quorum_percent)
|
|
param_idx += 1
|
|
|
|
if data.is_active is not None:
|
|
updates.append(f"is_active = ${param_idx}")
|
|
values.append(data.is_active)
|
|
param_idx += 1
|
|
|
|
if not updates:
|
|
return await self.get_dao_by_id(dao_id)
|
|
|
|
updates.append(f"updated_at = NOW()")
|
|
values.append(uuid.UUID(dao_id))
|
|
|
|
query = f"""
|
|
UPDATE dao
|
|
SET {', '.join(updates)}
|
|
WHERE id = ${param_idx}
|
|
RETURNING id
|
|
"""
|
|
|
|
async with self.db.acquire() as conn:
|
|
row = await conn.fetchrow(query, *values)
|
|
if not row:
|
|
return None
|
|
return await self._get_dao_by_id(conn, row['id'])
|
|
|
|
async def delete_dao(self, dao_id: str) -> bool:
|
|
"""Soft delete DAO (set is_active = false)"""
|
|
result = await self.db.execute(
|
|
"""
|
|
UPDATE dao
|
|
SET is_active = false, updated_at = NOW()
|
|
WHERE id = $1
|
|
""",
|
|
uuid.UUID(dao_id)
|
|
)
|
|
return result == "UPDATE 1"
|
|
|
|
async def get_dao_by_slug(self, slug: str) -> Optional[DaoRead]:
|
|
"""Get DAO by slug"""
|
|
async with self.db.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, slug, name, description, microdao_id, owner_user_id,
|
|
governance_model, voting_period_seconds, quorum_percent,
|
|
is_active, created_at, updated_at
|
|
FROM dao
|
|
WHERE slug = $1
|
|
""",
|
|
slug
|
|
)
|
|
|
|
if not row:
|
|
return None
|
|
|
|
return self._row_to_dao(row)
|
|
|
|
async def get_dao_by_id(self, dao_id: str) -> Optional[DaoRead]:
|
|
"""Get DAO by ID"""
|
|
async with self.db.acquire() as conn:
|
|
return await self._get_dao_by_id(conn, uuid.UUID(dao_id))
|
|
|
|
async def _get_dao_by_id(
|
|
self,
|
|
conn: asyncpg.Connection,
|
|
dao_id: uuid.UUID
|
|
) -> Optional[DaoRead]:
|
|
"""Internal: get DAO by ID within transaction"""
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, slug, name, description, microdao_id, owner_user_id,
|
|
governance_model, voting_period_seconds, quorum_percent,
|
|
is_active, created_at, updated_at
|
|
FROM dao
|
|
WHERE id = $1
|
|
""",
|
|
dao_id
|
|
)
|
|
|
|
if not row:
|
|
return None
|
|
|
|
return self._row_to_dao(row)
|
|
|
|
async def list_daos_for_user(self, user_id: str) -> List[DaoRead]:
|
|
"""List DAOs where user is a member"""
|
|
rows = await self.db.fetch(
|
|
"""
|
|
SELECT d.id, d.slug, d.name, d.description, d.microdao_id, d.owner_user_id,
|
|
d.governance_model, d.voting_period_seconds, d.quorum_percent,
|
|
d.is_active, d.created_at, d.updated_at
|
|
FROM dao d
|
|
INNER JOIN dao_members dm ON dm.dao_id = d.id
|
|
WHERE dm.user_id = $1 AND d.is_active = true
|
|
ORDER BY d.created_at DESC
|
|
""",
|
|
uuid.UUID(user_id)
|
|
)
|
|
|
|
return [self._row_to_dao(row) for row in rows]
|
|
|
|
async def get_dao_overview(self, dao_id: str) -> Optional[DaoOverview]:
|
|
"""Get DAO overview with aggregated stats"""
|
|
dao = await self.get_dao_by_id(dao_id)
|
|
if not dao:
|
|
return None
|
|
|
|
# Get counts
|
|
async with self.db.acquire() as conn:
|
|
members_count = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM dao_members WHERE dao_id = $1",
|
|
uuid.UUID(dao_id)
|
|
)
|
|
|
|
active_proposals_count = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM dao_proposals WHERE dao_id = $1 AND status = 'active'",
|
|
uuid.UUID(dao_id)
|
|
)
|
|
|
|
total_proposals_count = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM dao_proposals WHERE dao_id = $1",
|
|
uuid.UUID(dao_id)
|
|
)
|
|
|
|
# Get treasury items
|
|
treasury_items = await self.get_treasury_items(dao_id)
|
|
|
|
return DaoOverview(
|
|
dao=dao,
|
|
members_count=members_count or 0,
|
|
active_proposals_count=active_proposals_count or 0,
|
|
total_proposals_count=total_proposals_count or 0,
|
|
treasury_items=treasury_items
|
|
)
|
|
|
|
async def get_dao_overview_by_slug(self, slug: str) -> Optional[DaoOverview]:
|
|
"""Helper to get overview by slug"""
|
|
dao = await self.get_dao_by_slug(slug)
|
|
if not dao:
|
|
return None
|
|
return await self.get_dao_overview(dao.id)
|
|
|
|
def _row_to_dao(self, row: asyncpg.Record) -> DaoRead:
|
|
"""Convert database row to DaoRead"""
|
|
return DaoRead(
|
|
id=str(row['id']),
|
|
slug=row['slug'],
|
|
name=row['name'],
|
|
description=row['description'],
|
|
microdao_id=str(row['microdao_id']),
|
|
owner_user_id=str(row['owner_user_id']),
|
|
governance_model=row['governance_model'],
|
|
voting_period_seconds=row['voting_period_seconds'],
|
|
quorum_percent=row['quorum_percent'],
|
|
is_active=row['is_active'],
|
|
created_at=row['created_at'],
|
|
updated_at=row['updated_at']
|
|
)
|
|
|
|
# ========================================================================
|
|
# Members
|
|
# ========================================================================
|
|
|
|
async def list_members(self, dao_id: str) -> List[DaoMember]:
|
|
"""List all members of a DAO"""
|
|
rows = await self.db.fetch(
|
|
"""
|
|
SELECT id, dao_id, user_id, role, joined_at
|
|
FROM dao_members
|
|
WHERE dao_id = $1
|
|
ORDER BY joined_at ASC
|
|
""",
|
|
uuid.UUID(dao_id)
|
|
)
|
|
|
|
return [
|
|
DaoMember(
|
|
id=str(row['id']),
|
|
dao_id=str(row['dao_id']),
|
|
user_id=str(row['user_id']),
|
|
role=row['role'],
|
|
joined_at=row['joined_at']
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
async def add_member(
|
|
self,
|
|
dao_id: str,
|
|
user_id: str,
|
|
role: str = "member"
|
|
) -> DaoMember:
|
|
"""Add member to DAO"""
|
|
member_id = uuid.uuid4()
|
|
|
|
row = await self.db.fetchrow(
|
|
"""
|
|
INSERT INTO dao_members (id, dao_id, user_id, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (dao_id, user_id) DO UPDATE
|
|
SET role = EXCLUDED.role
|
|
RETURNING id, dao_id, user_id, role, joined_at
|
|
""",
|
|
member_id,
|
|
uuid.UUID(dao_id),
|
|
uuid.UUID(user_id),
|
|
role
|
|
)
|
|
|
|
return DaoMember(
|
|
id=str(row['id']),
|
|
dao_id=str(row['dao_id']),
|
|
user_id=str(row['user_id']),
|
|
role=row['role'],
|
|
joined_at=row['joined_at']
|
|
)
|
|
|
|
async def remove_member(self, member_id: str) -> bool:
|
|
"""Remove member from DAO"""
|
|
result = await self.db.execute(
|
|
"""
|
|
DELETE FROM dao_members
|
|
WHERE id = $1
|
|
""",
|
|
uuid.UUID(member_id)
|
|
)
|
|
return result == "DELETE 1"
|
|
|
|
async def get_user_role_in_dao(
|
|
self,
|
|
dao_id: str,
|
|
user_id: str
|
|
) -> Optional[str]:
|
|
"""Get user's role in DAO (or None if not a member)"""
|
|
row = await self.db.fetchrow(
|
|
"""
|
|
SELECT role FROM dao_members
|
|
WHERE dao_id = $1 AND user_id = $2
|
|
""",
|
|
uuid.UUID(dao_id),
|
|
uuid.UUID(user_id)
|
|
)
|
|
return row['role'] if row else None
|
|
|
|
# ========================================================================
|
|
# Treasury
|
|
# ========================================================================
|
|
|
|
async def get_treasury_items(self, dao_id: str) -> List[DaoTreasuryItem]:
|
|
"""Get all treasury items for a DAO"""
|
|
rows = await self.db.fetch(
|
|
"""
|
|
SELECT token_symbol, contract_address, balance
|
|
FROM dao_treasury
|
|
WHERE dao_id = $1
|
|
ORDER BY token_symbol
|
|
""",
|
|
uuid.UUID(dao_id)
|
|
)
|
|
|
|
return [
|
|
DaoTreasuryItem(
|
|
token_symbol=row['token_symbol'],
|
|
contract_address=row['contract_address'],
|
|
balance=row['balance']
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
async def apply_treasury_delta(
|
|
self,
|
|
dao_id: str,
|
|
token_symbol: str,
|
|
delta: Decimal
|
|
) -> DaoTreasuryItem:
|
|
"""Apply delta to treasury balance"""
|
|
async with self.db.acquire() as conn:
|
|
async with conn.transaction():
|
|
# Get current balance
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT balance FROM dao_treasury
|
|
WHERE dao_id = $1 AND token_symbol = $2
|
|
FOR UPDATE
|
|
""",
|
|
uuid.UUID(dao_id),
|
|
token_symbol
|
|
)
|
|
|
|
current_balance = row['balance'] if row else Decimal('0')
|
|
new_balance = current_balance + delta
|
|
|
|
if new_balance < 0:
|
|
raise ValueError(f"Insufficient balance: {current_balance} + {delta} = {new_balance} < 0")
|
|
|
|
# Upsert
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO dao_treasury (dao_id, token_symbol, balance, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (dao_id, token_symbol) DO UPDATE
|
|
SET balance = $3, updated_at = NOW()
|
|
RETURNING token_symbol, contract_address, balance
|
|
""",
|
|
uuid.UUID(dao_id),
|
|
token_symbol,
|
|
new_balance
|
|
)
|
|
|
|
return DaoTreasuryItem(
|
|
token_symbol=row['token_symbol'],
|
|
contract_address=row['contract_address'],
|
|
balance=row['balance']
|
|
)
|
|
|
|
async def set_treasury_balance(
|
|
self,
|
|
dao_id: str,
|
|
token_symbol: str,
|
|
balance: Decimal
|
|
) -> DaoTreasuryItem:
|
|
"""Set treasury balance directly"""
|
|
if balance < 0:
|
|
raise ValueError(f"Balance cannot be negative: {balance}")
|
|
|
|
row = await self.db.fetchrow(
|
|
"""
|
|
INSERT INTO dao_treasury (dao_id, token_symbol, balance, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (dao_id, token_symbol) DO UPDATE
|
|
SET balance = $3, updated_at = NOW()
|
|
RETURNING token_symbol, contract_address, balance
|
|
""",
|
|
uuid.UUID(dao_id),
|
|
token_symbol,
|
|
balance
|
|
)
|
|
|
|
return DaoTreasuryItem(
|
|
token_symbol=row['token_symbol'],
|
|
contract_address=row['contract_address'],
|
|
balance=row['balance']
|
|
)
|
|
|