feat: add organization and member management APIs for admin and internal use

This commit is contained in:
Chris
2026-03-30 01:23:02 +08:00
parent f00b8cefaa
commit 0f0b197b32
14 changed files with 701 additions and 2 deletions

View File

@@ -51,3 +51,11 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `POST /internal/authentik/users/ensure` - `POST /internal/authentik/users/ensure`
- `POST /admin/permissions/grant` - `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke` - `POST /admin/permissions/revoke`
- `GET|POST|PATCH /admin/organizations...`
- `GET|POST|PATCH /admin/members...`
- `GET|POST|DELETE /admin/members/{member_id}/organizations...`
- `GET /internal/members`
- `GET /internal/members/by-sub/{authentik_sub}`
- `GET /internal/organizations`
- `GET /internal/organizations/by-code/{org_code}`
- `GET /internal/members/{member_id}/organizations`

265
app/api/admin_entities.py Normal file
View File

@@ -0,0 +1,265 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.member_organizations_repo import MemberOrganizationsRepository
from app.repositories.organizations_repo import OrganizationsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.members import (
MemberCreateRequest,
MemberListResponse,
MemberOrganizationsResponse,
MemberSummary,
MemberUpdateRequest,
)
from app.schemas.organizations import (
OrganizationCreateRequest,
OrganizationListResponse,
OrganizationSummary,
OrganizationUpdateRequest,
)
from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"])
def _to_member_summary(member) -> MemberSummary:
return MemberSummary(
id=member.id,
authentik_sub=member.authentik_sub,
authentik_user_id=member.authentik_user_id,
email=member.email,
display_name=member.display_name,
is_active=member.is_active,
)
def _to_org_summary(org) -> OrganizationSummary:
return OrganizationSummary(
id=org.id,
org_code=org.org_code,
name=org.name,
tax_id=org.tax_id,
status=org.status,
)
@router.get("/organizations", response_model=OrganizationListResponse)
def list_organizations(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> OrganizationListResponse:
repo = OrganizationsRepository(db)
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
return OrganizationListResponse(items=[_to_org_summary(i) for i in items], total=total, limit=limit, offset=offset)
@router.post("/organizations", response_model=OrganizationSummary)
def create_organization(
payload: OrganizationCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> OrganizationSummary:
repo = OrganizationsRepository(db)
existing = repo.get_by_code(payload.org_code)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="organization_code_already_exists")
org = repo.create(org_code=payload.org_code, name=payload.name, tax_id=payload.tax_id, status=payload.status)
return _to_org_summary(org)
@router.get("/organizations/{org_id}", response_model=OrganizationSummary)
def get_organization(
org_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> OrganizationSummary:
repo = OrganizationsRepository(db)
org = repo.get_by_id(org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found")
return _to_org_summary(org)
@router.patch("/organizations/{org_id}", response_model=OrganizationSummary)
def update_organization(
org_id: str,
payload: OrganizationUpdateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> OrganizationSummary:
repo = OrganizationsRepository(db)
org = repo.get_by_id(org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found")
updated = repo.update(org, name=payload.name, tax_id=payload.tax_id, status=payload.status)
return _to_org_summary(updated)
@router.post("/organizations/{org_id}/activate", response_model=OrganizationSummary)
def activate_organization(
org_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> OrganizationSummary:
repo = OrganizationsRepository(db)
org = repo.get_by_id(org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found")
return _to_org_summary(repo.update(org, status="active"))
@router.post("/organizations/{org_id}/deactivate", response_model=OrganizationSummary)
def deactivate_organization(
org_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> OrganizationSummary:
repo = OrganizationsRepository(db)
org = repo.get_by_id(org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found")
return _to_org_summary(repo.update(org, status="inactive"))
@router.get("/members", response_model=MemberListResponse)
def list_members(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
is_active: bool | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> MemberListResponse:
repo = UsersRepository(db)
items, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset)
return MemberListResponse(items=[_to_member_summary(i) for i in items], total=total, limit=limit, offset=offset)
@router.post("/members", response_model=MemberSummary)
def create_member(
payload: MemberCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> MemberSummary:
repo = UsersRepository(db)
existing = repo.get_by_sub(payload.authentik_sub)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="member_sub_already_exists")
member = repo.upsert_by_sub(
authentik_sub=payload.authentik_sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
)
return _to_member_summary(member)
@router.get("/members/{member_id}", response_model=MemberSummary)
def get_member(
member_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> MemberSummary:
repo = UsersRepository(db)
member = repo.get_by_id(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
return _to_member_summary(member)
@router.patch("/members/{member_id}", response_model=MemberSummary)
def update_member(
member_id: str,
payload: MemberUpdateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> MemberSummary:
repo = UsersRepository(db)
member = repo.get_by_id(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
updated = repo.update_member(member, email=payload.email, display_name=payload.display_name, is_active=payload.is_active)
return _to_member_summary(updated)
@router.post("/members/{member_id}/activate", response_model=MemberSummary)
def activate_member(
member_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> MemberSummary:
repo = UsersRepository(db)
member = repo.get_by_id(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
return _to_member_summary(repo.update_member(member, is_active=True))
@router.post("/members/{member_id}/deactivate", response_model=MemberSummary)
def deactivate_member(
member_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> MemberSummary:
repo = UsersRepository(db)
member = repo.get_by_id(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
return _to_member_summary(repo.update_member(member, is_active=False))
@router.get("/members/{member_id}/organizations", response_model=MemberOrganizationsResponse)
def list_member_organizations(
member_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> MemberOrganizationsResponse:
users_repo = UsersRepository(db)
link_repo = MemberOrganizationsRepository(db)
member = users_repo.get_by_id(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
orgs = link_repo.list_organizations_by_member_id(member_id)
return MemberOrganizationsResponse(member=_to_member_summary(member), organizations=[_to_org_summary(o) for o in orgs])
@router.post("/members/{member_id}/organizations/{org_id}")
def bind_member_organization(
member_id: str,
org_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
users_repo = UsersRepository(db)
orgs_repo = OrganizationsRepository(db)
link_repo = MemberOrganizationsRepository(db)
member = users_repo.get_by_id(member_id)
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found")
org = orgs_repo.get_by_id(org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found")
relation = link_repo.add_if_not_exists(member_id=member_id, organization_id=org_id)
return {"relation_id": relation.id, "result": "bound"}
@router.delete("/members/{member_id}/organizations/{org_id}")
def unbind_member_organization(
member_id: str,
org_id: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
link_repo = MemberOrganizationsRepository(db)
deleted = link_repo.remove(member_id=member_id, organization_id=org_id)
return {"deleted": deleted, "result": "unbound"}

View File

@@ -0,0 +1,100 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.internal import verify_internal_secret
from app.db.session import get_db
from app.repositories.member_organizations_repo import MemberOrganizationsRepository
from app.repositories.organizations_repo import OrganizationsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.members import MemberListResponse, MemberOrganizationsResponse, MemberSummary
from app.schemas.organizations import OrganizationListResponse, OrganizationSummary
router = APIRouter(prefix="/internal", tags=["internal"])
def _to_member_summary(member) -> MemberSummary:
return MemberSummary(
id=member.id,
authentik_sub=member.authentik_sub,
authentik_user_id=member.authentik_user_id,
email=member.email,
display_name=member.display_name,
is_active=member.is_active,
)
def _to_org_summary(org) -> OrganizationSummary:
return OrganizationSummary(
id=org.id,
org_code=org.org_code,
name=org.name,
tax_id=org.tax_id,
status=org.status,
)
@router.get("/members", response_model=MemberListResponse)
def internal_list_members(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
is_active: bool | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> MemberListResponse:
repo = UsersRepository(db)
items, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset)
return MemberListResponse(items=[_to_member_summary(i) for i in items], total=total, limit=limit, offset=offset)
@router.get("/members/by-sub/{authentik_sub}", response_model=MemberSummary | None)
def internal_get_member_by_sub(
authentik_sub: str,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> MemberSummary | None:
repo = UsersRepository(db)
member = repo.get_by_sub(authentik_sub)
return _to_member_summary(member) if member else None
@router.get("/organizations", response_model=OrganizationListResponse)
def internal_list_organizations(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> OrganizationListResponse:
repo = OrganizationsRepository(db)
items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset)
return OrganizationListResponse(items=[_to_org_summary(i) for i in items], total=total, limit=limit, offset=offset)
@router.get("/organizations/by-code/{org_code}", response_model=OrganizationSummary | None)
def internal_get_organization_by_code(
org_code: str,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> OrganizationSummary | None:
repo = OrganizationsRepository(db)
org = repo.get_by_code(org_code)
return _to_org_summary(org) if org else None
@router.get("/members/{member_id}/organizations", response_model=MemberOrganizationsResponse | None)
def internal_list_member_organizations(
member_id: str,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> MemberOrganizationsResponse | None:
users_repo = UsersRepository(db)
link_repo = MemberOrganizationsRepository(db)
member = users_repo.get_by_id(member_id)
if not member:
return None
orgs = link_repo.list_organizations_by_member_id(member_id)
return MemberOrganizationsResponse(member=_to_member_summary(member), organizations=[_to_org_summary(o) for o in orgs])

View File

@@ -2,8 +2,10 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.admin_entities import router as admin_entities_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.internal import router as internal_router from app.api.internal import router as internal_router
from app.api.internal_entities import router as internal_entities_router
from app.api.me import router as me_router from app.api.me import router as me_router
from app.core.config import get_settings from app.core.config import get_settings
@@ -25,6 +27,8 @@ def healthz() -> dict[str, str]:
app.include_router(internal_router) app.include_router(internal_router)
app.include_router(internal_entities_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(admin_entities_router)
app.include_router(me_router) app.include_router(me_router)
app.include_router(auth_router) app.include_router(auth_router)

View File

@@ -1,5 +1,7 @@
from app.models.api_client import ApiClient from app.models.api_client import ApiClient
from app.models.member_organization import MemberOrganization
from app.models.organization import Organization
from app.models.permission import Permission from app.models.permission import Permission
from app.models.user import User from app.models.user import User
__all__ = ["ApiClient", "Permission", "User"] __all__ = ["ApiClient", "MemberOrganization", "Organization", "Permission", "User"]

View File

@@ -0,0 +1,23 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class MemberOrganization(Base):
__tablename__ = "member_organizations"
__table_args__ = (
UniqueConstraint("member_id", "organization_id", name="uq_member_organizations_member_org"),
)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
member_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
organization_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,23 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
org_code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
tax_id: Mapped[str | None] = mapped_column(String(32))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -0,0 +1,43 @@
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.member_organization import MemberOrganization
from app.models.organization import Organization
class MemberOrganizationsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_organizations_by_member_id(self, member_id: str) -> list[Organization]:
stmt = (
select(Organization)
.join(MemberOrganization, MemberOrganization.organization_id == Organization.id)
.where(MemberOrganization.member_id == member_id)
.order_by(Organization.name.asc())
)
return list(self.db.scalars(stmt).all())
def add_if_not_exists(self, member_id: str, organization_id: str) -> MemberOrganization:
stmt = select(MemberOrganization).where(
MemberOrganization.member_id == member_id,
MemberOrganization.organization_id == organization_id,
)
existing = self.db.scalar(stmt)
if existing:
return existing
relation = MemberOrganization(member_id=member_id, organization_id=organization_id)
self.db.add(relation)
self.db.commit()
self.db.refresh(relation)
return relation
def remove(self, member_id: str, organization_id: str) -> int:
stmt = delete(MemberOrganization).where(
MemberOrganization.member_id == member_id,
MemberOrganization.organization_id == organization_id,
)
result = self.db.execute(stmt)
self.db.commit()
return int(result.rowcount or 0)

View File

@@ -0,0 +1,67 @@
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.models.organization import Organization
class OrganizationsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(
self,
keyword: str | None = None,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Organization], int]:
stmt = select(Organization)
count_stmt = select(func.count()).select_from(Organization)
if keyword:
pattern = f"%{keyword}%"
cond = or_(Organization.org_code.ilike(pattern), Organization.name.ilike(pattern))
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if status:
stmt = stmt.where(Organization.status == status)
count_stmt = count_stmt.where(Organization.status == status)
stmt = stmt.order_by(Organization.created_at.desc()).limit(limit).offset(offset)
items = list(self.db.scalars(stmt).all())
total = int(self.db.scalar(count_stmt) or 0)
return items, total
def get_by_id(self, org_id: str) -> Organization | None:
stmt = select(Organization).where(Organization.id == org_id)
return self.db.scalar(stmt)
def get_by_code(self, org_code: str) -> Organization | None:
stmt = select(Organization).where(Organization.org_code == org_code)
return self.db.scalar(stmt)
def create(self, org_code: str, name: str, tax_id: str | None, status: str = "active") -> Organization:
org = Organization(org_code=org_code, name=name, tax_id=tax_id, status=status)
self.db.add(org)
self.db.commit()
self.db.refresh(org)
return org
def update(
self,
org: Organization,
*,
name: str | None = None,
tax_id: str | None = None,
status: str | None = None,
) -> Organization:
if name is not None:
org.name = name
if tax_id is not None:
org.tax_id = tax_id
if status is not None:
org.status = status
self.db.commit()
self.db.refresh(org)
return org

View File

@@ -1,4 +1,4 @@
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.user import User from app.models.user import User
@@ -12,6 +12,35 @@ class UsersRepository:
stmt = select(User).where(User.authentik_sub == authentik_sub) stmt = select(User).where(User.authentik_sub == authentik_sub)
return self.db.scalar(stmt) return self.db.scalar(stmt)
def get_by_id(self, user_id: str) -> User | None:
stmt = select(User).where(User.id == user_id)
return self.db.scalar(stmt)
def list(
self,
keyword: str | None = None,
is_active: bool | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[User], int]:
stmt = select(User)
count_stmt = select(func.count()).select_from(User)
if keyword:
pattern = f"%{keyword}%"
cond = or_(User.authentik_sub.ilike(pattern), User.email.ilike(pattern), User.display_name.ilike(pattern))
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if is_active is not None:
stmt = stmt.where(User.is_active == is_active)
count_stmt = count_stmt.where(User.is_active == is_active)
stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
items = list(self.db.scalars(stmt).all())
total = int(self.db.scalar(count_stmt) or 0)
return items, total
def upsert_by_sub( def upsert_by_sub(
self, self,
authentik_sub: str, authentik_sub: str,
@@ -40,3 +69,21 @@ class UsersRepository:
self.db.commit() self.db.commit()
self.db.refresh(user) self.db.refresh(user)
return user return user
def update_member(
self,
user: User,
*,
email: str | None = None,
display_name: str | None = None,
is_active: bool | None = None,
) -> User:
if email is not None:
user.email = email
if display_name is not None:
user.display_name = display_name
if is_active is not None:
user.is_active = is_active
self.db.commit()
self.db.refresh(user)
return user

37
app/schemas/members.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from app.schemas.organizations import OrganizationSummary
class MemberCreateRequest(BaseModel):
authentik_sub: str
email: str | None = None
display_name: str | None = None
is_active: bool = True
class MemberUpdateRequest(BaseModel):
email: str | None = None
display_name: str | None = None
is_active: bool | None = None
class MemberSummary(BaseModel):
id: str
authentik_sub: str
authentik_user_id: int | None = None
email: str | None = None
display_name: str | None = None
is_active: bool
class MemberListResponse(BaseModel):
items: list[MemberSummary]
total: int
limit: int
offset: int
class MemberOrganizationsResponse(BaseModel):
member: MemberSummary
organizations: list[OrganizationSummary]

View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel
class OrganizationCreateRequest(BaseModel):
org_code: str
name: str
tax_id: str | None = None
status: str = "active"
class OrganizationUpdateRequest(BaseModel):
name: str | None = None
tax_id: str | None = None
status: str | None = None
class OrganizationSummary(BaseModel):
id: str
org_code: str
name: str
tax_id: str | None = None
status: str
class OrganizationListResponse(BaseModel):
items: list[OrganizationSummary]
total: int
limit: int
offset: int

View File

@@ -28,4 +28,28 @@ CREATE TABLE IF NOT EXISTS permissions (
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub); CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id); CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
tax_id VARCHAR(32),
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_organizations_org_code ON organizations(org_code);
CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status);
CREATE TABLE IF NOT EXISTS member_organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_member_organizations_member_org UNIQUE (member_id, organization_id)
);
CREATE INDEX IF NOT EXISTS idx_member_organizations_member_id ON member_organizations(member_id);
CREATE INDEX IF NOT EXISTS idx_member_organizations_org_id ON member_organizations(organization_id);
COMMIT; COMMIT;

View File

@@ -0,0 +1,27 @@
BEGIN;
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
tax_id VARCHAR(32),
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_organizations_org_code ON organizations(org_code);
CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status);
CREATE TABLE IF NOT EXISTS member_organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_member_organizations_member_org UNIQUE (member_id, organization_id)
);
CREATE INDEX IF NOT EXISTS idx_member_organizations_member_id ON member_organizations(member_id);
CREATE INDEX IF NOT EXISTS idx_member_organizations_org_id ON member_organizations(organization_id);
COMMIT;