diff --git a/README.md b/README.md index 39c486b..7052551 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,11 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `POST /internal/authentik/users/ensure` - `POST /admin/permissions/grant` - `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` diff --git a/app/api/admin_entities.py b/app/api/admin_entities.py new file mode 100644 index 0000000..89c1531 --- /dev/null +++ b/app/api/admin_entities.py @@ -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"} diff --git a/app/api/internal_entities.py b/app/api/internal_entities.py new file mode 100644 index 0000000..b728341 --- /dev/null +++ b/app/api/internal_entities.py @@ -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]) diff --git a/app/main.py b/app/main.py index 7d35d36..e8ef733 100644 --- a/app/main.py +++ b/app/main.py @@ -2,8 +2,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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.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.core.config import get_settings @@ -25,6 +27,8 @@ def healthz() -> dict[str, str]: app.include_router(internal_router) +app.include_router(internal_entities_router) app.include_router(admin_router) +app.include_router(admin_entities_router) app.include_router(me_router) app.include_router(auth_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index f4457bd..44a7f90 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,7 @@ 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.user import User -__all__ = ["ApiClient", "Permission", "User"] +__all__ = ["ApiClient", "MemberOrganization", "Organization", "Permission", "User"] diff --git a/app/models/member_organization.py b/app/models/member_organization.py new file mode 100644 index 0000000..19ec119 --- /dev/null +++ b/app/models/member_organization.py @@ -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) diff --git a/app/models/organization.py b/app/models/organization.py new file mode 100644 index 0000000..12ee320 --- /dev/null +++ b/app/models/organization.py @@ -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 + ) diff --git a/app/repositories/member_organizations_repo.py b/app/repositories/member_organizations_repo.py new file mode 100644 index 0000000..d9345a0 --- /dev/null +++ b/app/repositories/member_organizations_repo.py @@ -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) diff --git a/app/repositories/organizations_repo.py b/app/repositories/organizations_repo.py new file mode 100644 index 0000000..627d8cc --- /dev/null +++ b/app/repositories/organizations_repo.py @@ -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 diff --git a/app/repositories/users_repo.py b/app/repositories/users_repo.py index a590cd4..a45c690 100644 --- a/app/repositories/users_repo.py +++ b/app/repositories/users_repo.py @@ -1,4 +1,4 @@ -from sqlalchemy import select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.models.user import User @@ -12,6 +12,35 @@ class UsersRepository: stmt = select(User).where(User.authentik_sub == authentik_sub) 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( self, authentik_sub: str, @@ -40,3 +69,21 @@ class UsersRepository: self.db.commit() self.db.refresh(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 diff --git a/app/schemas/members.py b/app/schemas/members.py new file mode 100644 index 0000000..c275ef3 --- /dev/null +++ b/app/schemas/members.py @@ -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] diff --git a/app/schemas/organizations.py b/app/schemas/organizations.py new file mode 100644 index 0000000..019ac4c --- /dev/null +++ b/app/schemas/organizations.py @@ -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 diff --git a/scripts/init_schema.sql b/scripts/init_schema.sql index 49e82ba..d87c6b4 100644 --- a/scripts/init_schema.sql +++ b/scripts/init_schema.sql @@ -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_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; diff --git a/scripts/migrate_add_org_member_tables.sql b/scripts/migrate_add_org_member_tables.sql new file mode 100644 index 0000000..4f076aa --- /dev/null +++ b/scripts/migrate_add_org_member_tables.sql @@ -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;