diff --git a/backend/alembic/versions/87dd17a9cb8d_branches.py b/backend/alembic/versions/87dd17a9cb8d_branches.py new file mode 100644 index 0000000..582d888 --- /dev/null +++ b/backend/alembic/versions/87dd17a9cb8d_branches.py @@ -0,0 +1,55 @@ +"""branches + +Revision ID: 87dd17a9cb8d +Revises: 4315ee2f32f5 +Create Date: 2026-08-19 00:00:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "87dd17a9cb8d" +down_revision: str | None = "4315ee2f32f5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +UPGRADE = """ +CREATE TABLE branch_drafts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + icon_key TEXT NOT NULL, + color TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX ix_branch_drafts_position ON branch_drafts (position); + +CREATE TABLE branches ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon_key TEXT NOT NULL, + color TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX ix_branches_position ON branches (position); +""" + +DOWNGRADE = """ +DROP TABLE IF EXISTS branches; +DROP TABLE IF EXISTS branch_drafts; +""" + + +def upgrade() -> None: + op.execute(UPGRADE) + + +def downgrade() -> None: + op.execute(DOWNGRADE) diff --git a/backend/src/admin/api/dependencies.py b/backend/src/admin/api/dependencies.py index 40f7903..92bae84 100644 --- a/backend/src/admin/api/dependencies.py +++ b/backend/src/admin/api/dependencies.py @@ -23,6 +23,8 @@ from admin.repositories import ( AccessRequestRepository, AuditRepository, + BranchDraftRepository, + BranchRepository, InvitationRepository, MediaRepository, RoleRepository, @@ -32,6 +34,7 @@ from admin.schemas.user import UserRead from admin.services.access import AccessService from admin.services.access_request import AccessRequestService +from admin.services.branch import BranchService from admin.services.invitation import InvitationService from admin.services.media import MediaService from admin.services.member import MemberService @@ -111,6 +114,14 @@ def get_media_repository(connection: Connection) -> MediaRepository: return MediaRepository(connection) +def get_branch_draft_repository(connection: Connection) -> BranchDraftRepository: + return BranchDraftRepository(connection) + + +def get_branch_repository(connection: Connection) -> BranchRepository: + return BranchRepository(connection) + + Users = Annotated[UserRepository, Depends(get_user_repository)] Roles = Annotated[RoleRepository, Depends(get_role_repository)] Invitations = Annotated[InvitationRepository, Depends(get_invitation_repository)] @@ -118,6 +129,8 @@ def get_media_repository(connection: Connection) -> MediaRepository: Audit = Annotated[AuditLog, Depends(get_audit_log)] AuditEntries = Annotated[AuditRepository, Depends(get_audit_repository)] Media = Annotated[MediaRepository, Depends(get_media_repository)] +BranchDrafts = Annotated[BranchDraftRepository, Depends(get_branch_draft_repository)] +Branches = Annotated[BranchRepository, Depends(get_branch_repository)] def get_access_service( @@ -171,11 +184,18 @@ def get_media_service(media: Media, storage: Storage, audit: Audit) -> MediaServ return MediaService(media=media, storage=storage, audit=audit) +def get_branch_service( + drafts: BranchDrafts, published: Branches, storage: Storage, audit: Audit +) -> BranchService: + return BranchService(drafts=drafts, published=published, storage=storage, audit=audit) + + AccessServiceDep = Annotated[AccessService, Depends(get_access_service)] MemberServiceDep = Annotated[MemberService, Depends(get_member_service)] InvitationServiceDep = Annotated[InvitationService, Depends(get_invitation_service)] AccessRequestServiceDep = Annotated[AccessRequestService, Depends(get_access_request_service)] MediaServiceDep = Annotated[MediaService, Depends(get_media_service)] +BranchServiceDep = Annotated[BranchService, Depends(get_branch_service)] async def get_identity( diff --git a/backend/src/admin/api/public/__init__.py b/backend/src/admin/api/public/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/admin/api/public/v1/__init__.py b/backend/src/admin/api/public/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/admin/api/public/v1/branches.py b/backend/src/admin/api/public/v1/branches.py new file mode 100644 index 0000000..c8779c7 --- /dev/null +++ b/backend/src/admin/api/public/v1/branches.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from admin.api.dependencies import BranchServiceDep +from admin.schemas.branch import BranchRead + +router = APIRouter(prefix="/branches", tags=["public"]) + + +@router.get("", response_model=list[BranchRead]) +async def list_public_branches(service: BranchServiceDep) -> list[BranchRead]: + return await service.list_published() diff --git a/backend/src/admin/api/router.py b/backend/src/admin/api/router.py index f08e97c..e2fce22 100644 --- a/backend/src/admin/api/router.py +++ b/backend/src/admin/api/router.py @@ -1,8 +1,10 @@ from fastapi import APIRouter +from admin.api.public.v1 import branches as public_branches from admin.api.v1 import ( access_requests, audit, + branches, health, invitations, media, @@ -19,6 +21,10 @@ api_router.include_router(roles.router) api_router.include_router(media.router) api_router.include_router(audit.router) +api_router.include_router(branches.router) + +public_router = APIRouter(prefix="/public/v1") +public_router.include_router(public_branches.router) root_router = APIRouter() root_router.include_router(health.router) diff --git a/backend/src/admin/api/v1/branches.py b/backend/src/admin/api/v1/branches.py new file mode 100644 index 0000000..db5e562 --- /dev/null +++ b/backend/src/admin/api/v1/branches.py @@ -0,0 +1,86 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from admin.api.dependencies import AuthContext, BranchServiceDep, require +from admin.domain.permissions import Permission +from admin.schemas.branch import ( + BranchDraftCreate, + BranchDraftRead, + BranchDraftReorder, + BranchDraftUpdate, + BranchPublishStatus, + BranchRead, +) + +router = APIRouter(prefix="/branches", tags=["branches"]) + + +@router.get("/drafts", response_model=list[BranchDraftRead]) +async def list_drafts( + service: BranchServiceDep, + _: Annotated[AuthContext, Depends(require(Permission.BRANCHES_READ))], +) -> list[BranchDraftRead]: + return await service.list_drafts() + + +@router.post("/drafts", response_model=BranchDraftRead, status_code=status.HTTP_201_CREATED) +async def create_draft( + payload: BranchDraftCreate, + service: BranchServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))], +) -> BranchDraftRead: + return await service.create_draft(actor=context.user, payload=payload) + + +@router.patch("/drafts/{branch_id}", response_model=BranchDraftRead) +async def update_draft( + branch_id: uuid.UUID, + payload: BranchDraftUpdate, + service: BranchServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))], +) -> BranchDraftRead: + return await service.update_draft(actor=context.user, branch_id=branch_id, payload=payload) + + +@router.delete("/drafts/{branch_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_draft( + branch_id: uuid.UUID, + service: BranchServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))], +) -> None: + await service.delete_draft(actor=context.user, branch_id=branch_id) + + +@router.post("/drafts/reorder", response_model=list[BranchDraftRead]) +async def reorder_drafts( + payload: BranchDraftReorder, + service: BranchServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_MANAGE))], +) -> list[BranchDraftRead]: + return await service.reorder_drafts(actor=context.user, ordered_ids=payload.ids) + + +@router.get("/status", response_model=BranchPublishStatus) +async def publish_status( + service: BranchServiceDep, + _: Annotated[AuthContext, Depends(require(Permission.BRANCHES_READ))], +) -> BranchPublishStatus: + return await service.publish_status() + + +@router.post("/publish", response_model=list[BranchRead]) +async def publish( + service: BranchServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_PUBLISH))], +) -> list[BranchRead]: + return await service.publish(actor=context.user) + + +@router.post("/discard", response_model=list[BranchDraftRead]) +async def discard( + service: BranchServiceDep, + context: Annotated[AuthContext, Depends(require(Permission.BRANCHES_PUBLISH))], +) -> list[BranchDraftRead]: + return await service.discard_drafts(actor=context.user) diff --git a/backend/src/admin/domain/enums.py b/backend/src/admin/domain/enums.py index b567e2c..02af68b 100644 --- a/backend/src/admin/domain/enums.py +++ b/backend/src/admin/domain/enums.py @@ -31,6 +31,7 @@ class AccessRequestStatus(StrEnum): class MediaPurpose(StrEnum): AVATAR = "avatar" + BRANCH_ICON = "branch_icon" class AuditAction(StrEnum): @@ -47,3 +48,9 @@ class AuditAction(StrEnum): ACCESS_REQUEST_DENIED = "access_request.denied" MEDIA_UPLOADED = "media.uploaded" MEDIA_DELETED = "media.deleted" + BRANCH_DRAFT_CREATED = "branch_draft.created" + BRANCH_DRAFT_UPDATED = "branch_draft.updated" + BRANCH_DRAFT_DELETED = "branch_draft.deleted" + BRANCH_DRAFTS_REORDERED = "branch_draft.reordered" + BRANCHES_PUBLISHED = "branches.published" + BRANCHES_DISCARDED = "branches.discarded" diff --git a/backend/src/admin/domain/media.py b/backend/src/admin/domain/media.py index 87286ba..2050b90 100644 --- a/backend/src/admin/domain/media.py +++ b/backend/src/admin/domain/media.py @@ -19,6 +19,12 @@ class MediaPreset: visibility=MediaVisibility.PUBLIC, mime_types=frozenset({"image/jpeg", "image/png", "image/webp"}), ), + MediaPurpose.BRANCH_ICON: MediaPreset( + max_edge=256, + max_bytes=262_144, + visibility=MediaVisibility.PUBLIC, + mime_types=frozenset({"image/jpeg", "image/png", "image/webp"}), + ), } diff --git a/backend/src/admin/domain/permissions.py b/backend/src/admin/domain/permissions.py index 33aef2e..fd3caad 100644 --- a/backend/src/admin/domain/permissions.py +++ b/backend/src/admin/domain/permissions.py @@ -14,6 +14,9 @@ class Permission(StrEnum): AUDIT_READ = "core.audit.read" MEDIA_READ = "core.media.read" MEDIA_DELETE = "core.media.delete" + BRANCHES_READ = "core.branches.read" + BRANCHES_MANAGE = "core.branches.manage" + BRANCHES_PUBLISH = "core.branches.publish" @property def description(self) -> str: @@ -32,6 +35,9 @@ def description(self) -> str: Permission.AUDIT_READ: "Read the audit log", Permission.MEDIA_READ: "View private files uploaded by other members", Permission.MEDIA_DELETE: "Delete files uploaded by other members", + Permission.BRANCHES_READ: "View branch drafts and publish status", + Permission.BRANCHES_MANAGE: "Create, edit, delete, and reorder branch drafts", + Permission.BRANCHES_PUBLISH: "Publish or discard branch draft changes", } diff --git a/backend/src/admin/main.py b/backend/src/admin/main.py index 129f694..3ac3b47 100644 --- a/backend/src/admin/main.py +++ b/backend/src/admin/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from admin.api.router import api_router, root_router +from admin.api.router import api_router, public_router, root_router from admin.core.cache import build_cache from admin.core.config import Settings, get_settings from admin.core.database import create_pool @@ -82,6 +82,7 @@ async def handle_domain_error(_: Request, error: DomainError) -> JSONResponse: app.include_router(root_router) app.include_router(api_router) + app.include_router(public_router) return app diff --git a/backend/src/admin/repositories/__init__.py b/backend/src/admin/repositories/__init__.py index a908125..1cbeaa8 100644 --- a/backend/src/admin/repositories/__init__.py +++ b/backend/src/admin/repositories/__init__.py @@ -1,5 +1,6 @@ from admin.repositories.access_request import AccessRequestRepository from admin.repositories.audit import AuditRepository +from admin.repositories.branch import BranchDraftRepository, BranchRepository from admin.repositories.invitation import InvitationRepository from admin.repositories.media import MediaRepository from admin.repositories.role import RoleRepository @@ -8,6 +9,8 @@ __all__ = [ "AccessRequestRepository", "AuditRepository", + "BranchDraftRepository", + "BranchRepository", "InvitationRepository", "MediaRepository", "RoleRepository", diff --git a/backend/src/admin/repositories/branch.py b/backend/src/admin/repositories/branch.py new file mode 100644 index 0000000..553b504 --- /dev/null +++ b/backend/src/admin/repositories/branch.py @@ -0,0 +1,129 @@ +import uuid +from datetime import datetime + +from admin.repositories.base import Repository, required_row +from admin.schemas.branch import BranchDraftRecord, BranchRecord + +BRANCH_DRAFT_SELECT = """ +SELECT id, name, description, icon_key, color, position, created_at, updated_at +FROM branch_drafts +""" + +BRANCH_SELECT = """ +SELECT id, name, description, icon_key, color, position +FROM branches +""" + + +class BranchDraftRepository(Repository): + async def list_all(self) -> list[BranchDraftRecord]: + rows = await self.connection.fetch(f"{BRANCH_DRAFT_SELECT} ORDER BY position, created_at") + return BranchDraftRecord.from_rows(rows) + + async def get_by_id(self, branch_id: uuid.UUID) -> BranchDraftRecord | None: + row = await self.connection.fetchrow(f"{BRANCH_DRAFT_SELECT} WHERE id = $1", branch_id) + return BranchDraftRecord.from_optional_row(row) + + async def create( + self, + *, + name: str, + description: str | None, + icon_key: str, + color: str, + position: int, + ) -> BranchDraftRecord: + created = required_row( + await self.connection.fetchrow( + """ + INSERT INTO branch_drafts (name, description, icon_key, color, position) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + """, + name, + description, + icon_key, + color, + position, + ) + ) + draft = await self.get_by_id(created["id"]) + if draft is None: + raise RuntimeError("branch draft disappeared immediately after insert") + return draft + + async def update( + self, + branch_id: uuid.UUID, + *, + name: str | None, + description: str | None, + icon_key: str | None, + color: str | None, + position: int | None, + ) -> BranchDraftRecord | None: + row = await self.connection.fetchrow( + """ + UPDATE branch_drafts + SET name = COALESCE($2, name), + description = COALESCE($3, description), + icon_key = COALESCE($4, icon_key), + color = COALESCE($5, color), + position = COALESCE($6, position), + updated_at = now() + WHERE id = $1 + RETURNING id + """, + branch_id, + name, + description, + icon_key, + color, + position, + ) + if row is None: + return None + return await self.get_by_id(row["id"]) + + async def delete(self, branch_id: uuid.UUID) -> bool: + result = await self.connection.execute("DELETE FROM branch_drafts WHERE id = $1", branch_id) + return result.endswith("1") + + async def reorder(self, ordered_ids: list[uuid.UUID]) -> None: + await self.connection.executemany( + "UPDATE branch_drafts SET position = $2, updated_at = now() WHERE id = $1", + [(branch_id, position) for position, branch_id in enumerate(ordered_ids)], + ) + + async def replace_all(self, branches: list[BranchRecord]) -> None: + await self.connection.execute("DELETE FROM branch_drafts") + if not branches: + return + await self.connection.executemany( + """ + INSERT INTO branch_drafts (id, name, description, icon_key, color, position) + VALUES ($1, $2, $3, $4, $5, $6) + """, + [(b.id, b.name, b.description, b.icon_key, b.color, b.position) for b in branches], + ) + + +class BranchRepository(Repository): + async def list_all(self) -> list[BranchRecord]: + rows = await self.connection.fetch(f"{BRANCH_SELECT} ORDER BY position") + return BranchRecord.from_rows(rows) + + async def last_published_at(self) -> datetime | None: + return await self.connection.fetchval("SELECT max(created_at) FROM branches") + + async def replace_all(self, drafts: list[BranchDraftRecord]) -> None: + await self.connection.execute("DELETE FROM branches") + if not drafts: + return + await self.connection.executemany( + """ + INSERT INTO branches (id, name, description, icon_key, color, position) + VALUES ($1, $2, $3, $4, $5, $6) + """, + [(d.id, d.name, d.description, d.icon_key, d.color, d.position) for d in drafts], + ) diff --git a/backend/src/admin/schemas/branch.py b/backend/src/admin/schemas/branch.py new file mode 100644 index 0000000..bfe50d9 --- /dev/null +++ b/backend/src/admin/schemas/branch.py @@ -0,0 +1,76 @@ +import uuid +from datetime import datetime + +from pydantic import Field + +from admin.schemas.base import ReadDTO, RequestDTO + +COLOR_PATTERN = r"^#[0-9a-fA-F]{6}$" + + +class BranchDraftCreate(RequestDTO): + name: str = Field(min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=500) + icon_key: str = Field(min_length=1, max_length=512) + color: str = Field(pattern=COLOR_PATTERN) + position: int = Field(default=0, ge=0) + + +class BranchDraftUpdate(RequestDTO): + name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=500) + icon_key: str | None = Field(default=None, min_length=1, max_length=512) + color: str | None = Field(default=None, pattern=COLOR_PATTERN) + position: int | None = Field(default=None, ge=0) + + +class BranchDraftReorder(RequestDTO): + ids: list[uuid.UUID] = Field(min_length=1) + + +class BranchDraftRecord(ReadDTO): + id: uuid.UUID + name: str + description: str | None + icon_key: str + color: str + position: int + created_at: datetime + updated_at: datetime + + +class BranchRecord(ReadDTO): + id: uuid.UUID + name: str + description: str | None + icon_key: str + color: str + position: int + + +class BranchDraftRead(ReadDTO): + id: uuid.UUID + name: str + description: str | None + icon_key: str + icon_url: str + color: str + position: int + created_at: datetime + updated_at: datetime + + +class BranchRead(ReadDTO): + id: uuid.UUID + name: str + description: str | None + icon_url: str + color: str + position: int + + +class BranchPublishStatus(ReadDTO): + is_dirty: bool + draft_count: int + published_count: int + last_published_at: datetime | None diff --git a/backend/src/admin/services/branch.py b/backend/src/admin/services/branch.py new file mode 100644 index 0000000..a59071a --- /dev/null +++ b/backend/src/admin/services/branch.py @@ -0,0 +1,224 @@ +import uuid + +from admin.core.audit import AuditLog +from admin.core.errors import NotFoundError, ValidationError +from admin.core.storage import S3Storage +from admin.domain.enums import AuditAction +from admin.repositories.branch import BranchDraftRepository, BranchRepository +from admin.schemas.audit import AuditEntry +from admin.schemas.branch import ( + BranchDraftCreate, + BranchDraftRead, + BranchDraftRecord, + BranchDraftUpdate, + BranchPublishStatus, + BranchRead, + BranchRecord, +) +from admin.schemas.user import UserRead + +RESOURCE_TYPE = "branch" + +type BranchFingerprint = tuple[uuid.UUID, str, str | None, str, str] + + +class BranchService: + def __init__( + self, + *, + drafts: BranchDraftRepository, + published: BranchRepository, + storage: S3Storage, + audit: AuditLog, + ) -> None: + self._drafts = drafts + self._published = published + self._storage = storage + self._audit = audit + + async def list_drafts(self) -> list[BranchDraftRead]: + return [self._to_draft_read(draft) for draft in await self._drafts.list_all()] + + async def list_published(self) -> list[BranchRead]: + return [self._to_read(branch) for branch in await self._published.list_all()] + + async def create_draft(self, *, actor: UserRead, payload: BranchDraftCreate) -> BranchDraftRead: + draft = await self._drafts.create( + name=payload.name, + description=payload.description, + icon_key=payload.icon_key, + color=payload.color, + position=payload.position, + ) + + self._audit.add( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.BRANCH_DRAFT_CREATED, + resource_type=RESOURCE_TYPE, + resource_id=str(draft.id), + after={"name": draft.name, "icon_key": draft.icon_key, "color": draft.color}, + ) + ) + return self._to_draft_read(draft) + + async def update_draft( + self, *, actor: UserRead, branch_id: uuid.UUID, payload: BranchDraftUpdate + ) -> BranchDraftRead: + before = await self._require_draft(branch_id) + + updated = await self._drafts.update( + branch_id, + name=payload.name, + description=payload.description, + icon_key=payload.icon_key, + color=payload.color, + position=payload.position, + ) + if updated is None: + raise NotFoundError("branch draft does not exist") + + self._audit.add( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.BRANCH_DRAFT_UPDATED, + resource_type=RESOURCE_TYPE, + resource_id=str(branch_id), + before={ + "name": before.name, + "icon_key": before.icon_key, + "color": before.color, + }, + after={ + "name": updated.name, + "icon_key": updated.icon_key, + "color": updated.color, + }, + ) + ) + return self._to_draft_read(updated) + + async def delete_draft(self, *, actor: UserRead, branch_id: uuid.UUID) -> None: + before = await self._require_draft(branch_id) + + if not await self._drafts.delete(branch_id): + raise NotFoundError("branch draft does not exist") + + self._audit.add( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.BRANCH_DRAFT_DELETED, + resource_type=RESOURCE_TYPE, + resource_id=str(branch_id), + before={"name": before.name}, + ) + ) + + async def reorder_drafts( + self, *, actor: UserRead, ordered_ids: list[uuid.UUID] + ) -> list[BranchDraftRead]: + current_ids = {draft.id for draft in await self._drafts.list_all()} + if len(ordered_ids) != len(current_ids) or set(ordered_ids) != current_ids: + raise ValidationError( + "reorder must include exactly the current set of branch drafts, no more, no less" + ) + + await self._drafts.reorder(ordered_ids) + + self._audit.add( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.BRANCH_DRAFTS_REORDERED, + resource_type=RESOURCE_TYPE, + resource_id="*", + after={"order": [str(branch_id) for branch_id in ordered_ids]}, + ) + ) + return await self.list_drafts() + + async def publish_status(self) -> BranchPublishStatus: + drafts = await self._drafts.list_all() + published = await self._published.list_all() + return BranchPublishStatus( + is_dirty=self._fingerprint(drafts) != self._fingerprint(published), + draft_count=len(drafts), + published_count=len(published), + last_published_at=await self._published.last_published_at(), + ) + + async def publish(self, *, actor: UserRead) -> list[BranchRead]: + drafts = await self._drafts.list_all() + before_count = len(await self._published.list_all()) + + await self._published.replace_all(drafts) + + self._audit.add( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.BRANCHES_PUBLISHED, + resource_type=RESOURCE_TYPE, + resource_id="*", + before={"count": before_count}, + after={"count": len(drafts)}, + ) + ) + return await self.list_published() + + async def discard_drafts(self, *, actor: UserRead) -> list[BranchDraftRead]: + published = await self._published.list_all() + before_count = len(await self._drafts.list_all()) + + await self._drafts.replace_all(published) + + self._audit.add( + AuditEntry( + actor_id=actor.id, + actor_email=actor.email, + action=AuditAction.BRANCHES_DISCARDED, + resource_type=RESOURCE_TYPE, + resource_id="*", + before={"count": before_count}, + after={"count": len(published)}, + ) + ) + return await self.list_drafts() + + async def _require_draft(self, branch_id: uuid.UUID) -> BranchDraftRecord: + draft = await self._drafts.get_by_id(branch_id) + if draft is None: + raise NotFoundError("branch draft does not exist") + return draft + + def _to_draft_read(self, record: BranchDraftRecord) -> BranchDraftRead: + return BranchDraftRead( + id=record.id, + name=record.name, + description=record.description, + icon_key=record.icon_key, + icon_url=self._storage.public_url(record.icon_key), + color=record.color, + position=record.position, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + def _to_read(self, record: BranchRecord) -> BranchRead: + return BranchRead( + id=record.id, + name=record.name, + description=record.description, + icon_url=self._storage.public_url(record.icon_key), + color=record.color, + position=record.position, + ) + + @staticmethod + def _fingerprint( + items: list[BranchDraftRecord] | list[BranchRecord], + ) -> list[BranchFingerprint]: + return [(item.id, item.name, item.description, item.icon_key, item.color) for item in items] diff --git a/openapi.json b/openapi.json index 0e49628..2bbba8d 100644 --- a/openapi.json +++ b/openapi.json @@ -1045,6 +1045,338 @@ } } } + }, + "/api/v1/branches/drafts": { + "get": { + "tags": [ + "branches" + ], + "summary": "List Drafts", + "operationId": "listDrafts", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BranchDraftRead" + }, + "type": "array", + "title": "Response Listdrafts" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "branches" + ], + "summary": "Create Draft", + "operationId": "createDraft", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchDraftCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchDraftRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/branches/drafts/{branch_id}": { + "patch": { + "tags": [ + "branches" + ], + "summary": "Update Draft", + "operationId": "updateDraft", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "branch_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Branch Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchDraftUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchDraftRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "branches" + ], + "summary": "Delete Draft", + "operationId": "deleteDraft", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "branch_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Branch Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/branches/drafts/reorder": { + "post": { + "tags": [ + "branches" + ], + "summary": "Reorder Drafts", + "operationId": "reorderDrafts", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchDraftReorder" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BranchDraftRead" + }, + "type": "array", + "title": "Response Reorderdrafts" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/branches/status": { + "get": { + "tags": [ + "branches" + ], + "summary": "Publish Status", + "operationId": "publishStatus", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchPublishStatus" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/branches/publish": { + "post": { + "tags": [ + "branches" + ], + "summary": "Publish", + "operationId": "publish", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BranchRead" + }, + "type": "array", + "title": "Response Publish" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/branches/discard": { + "post": { + "tags": [ + "branches" + ], + "summary": "Discard", + "operationId": "discard", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BranchDraftRead" + }, + "type": "array", + "title": "Response Discard" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/public/v1/branches": { + "get": { + "tags": [ + "public" + ], + "summary": "List Public Branches", + "operationId": "listPublicBranches", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BranchRead" + }, + "type": "array", + "title": "Response Listpublicbranches" + } + } + } + } + } + } } }, "components": { @@ -1269,7 +1601,13 @@ "access_request.approved", "access_request.denied", "media.uploaded", - "media.deleted" + "media.deleted", + "branch_draft.created", + "branch_draft.updated", + "branch_draft.deleted", + "branch_draft.reordered", + "branches.published", + "branches.discarded" ], "title": "AuditAction" }, @@ -1354,6 +1692,286 @@ ], "title": "AuditLogRead" }, + "BranchDraftCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icon_key": { + "type": "string", + "maxLength": 512, + "minLength": 1, + "title": "Icon Key" + }, + "color": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "title": "Color" + }, + "position": { + "type": "integer", + "minimum": 0.0, + "title": "Position", + "default": 0 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "icon_key", + "color" + ], + "title": "BranchDraftCreate" + }, + "BranchDraftRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icon_key": { + "type": "string", + "title": "Icon Key" + }, + "icon_url": { + "type": "string", + "title": "Icon Url" + }, + "color": { + "type": "string", + "title": "Color" + }, + "position": { + "type": "integer", + "title": "Position" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "description", + "icon_key", + "icon_url", + "color", + "position", + "created_at", + "updated_at" + ], + "title": "BranchDraftRead" + }, + "BranchDraftReorder": { + "properties": { + "ids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "minItems": 1, + "title": "Ids" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "ids" + ], + "title": "BranchDraftReorder" + }, + "BranchDraftUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icon_key": { + "anyOf": [ + { + "type": "string", + "maxLength": 512, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Icon Key" + }, + "color": { + "anyOf": [ + { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "position": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Position" + } + }, + "additionalProperties": false, + "type": "object", + "title": "BranchDraftUpdate" + }, + "BranchPublishStatus": { + "properties": { + "is_dirty": { + "type": "boolean", + "title": "Is Dirty" + }, + "draft_count": { + "type": "integer", + "title": "Draft Count" + }, + "published_count": { + "type": "integer", + "title": "Published Count" + }, + "last_published_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Published At" + } + }, + "type": "object", + "required": [ + "is_dirty", + "draft_count", + "published_count", + "last_published_at" + ], + "title": "BranchPublishStatus" + }, + "BranchRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icon_url": { + "type": "string", + "title": "Icon Url" + }, + "color": { + "type": "string", + "title": "Color" + }, + "position": { + "type": "integer", + "title": "Position" + } + }, + "type": "object", + "required": [ + "id", + "name", + "description", + "icon_url", + "color", + "position" + ], + "title": "BranchRead" + }, "CursorPage_AuditLogRead_": { "properties": { "items": { @@ -1620,7 +2238,8 @@ "MediaPurpose": { "type": "string", "enum": [ - "avatar" + "avatar", + "branch_icon" ], "title": "MediaPurpose" }, diff --git a/packages/api/src/generated/branches/branches.ts b/packages/api/src/generated/branches/branches.ts new file mode 100644 index 0000000..037c5de --- /dev/null +++ b/packages/api/src/generated/branches/branches.ts @@ -0,0 +1,791 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + BranchDraftCreate, + BranchDraftRead, + BranchDraftReorder, + BranchDraftUpdate, + BranchPublishStatus, + BranchRead, + HTTPValidationError +} from '.././model'; + +import { apiFetch } from '../../http'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary List Drafts + */ +export type listDraftsResponse200 = { + data: BranchDraftRead[] + status: 200 +} + +export type listDraftsResponseSuccess = (listDraftsResponse200) & { + headers: Headers; +}; +; + +export type listDraftsResponse = (listDraftsResponseSuccess) + +export const getListDraftsUrl = () => { + + + + + return `/api/v1/branches/drafts` +} + +export const listDrafts = async ( options?: RequestInit): Promise => { + + return apiFetch(getListDraftsUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getListDraftsQueryKey = () => { + return [ + `/api/v1/branches/drafts` + ] as const; + } + + +export const getListDraftsQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListDraftsQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => listDrafts({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListDraftsQueryResult = NonNullable>> +export type ListDraftsQueryError = unknown + + +export function useListDrafts>, TError = unknown>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListDrafts>, TError = unknown>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListDrafts>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary List Drafts + */ + +export function useListDrafts>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListDraftsQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary Create Draft + */ +export type createDraftResponse201 = { + data: BranchDraftRead + status: 201 +} + +export type createDraftResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type createDraftResponseSuccess = (createDraftResponse201) & { + headers: Headers; +}; +export type createDraftResponseError = (createDraftResponse422) & { + headers: Headers; +}; + +export type createDraftResponse = (createDraftResponseSuccess | createDraftResponseError) + +export const getCreateDraftUrl = () => { + + + + + return `/api/v1/branches/drafts` +} + +export const createDraft = async (branchDraftCreate: BranchDraftCreate, options?: RequestInit): Promise => { + + return apiFetch(getCreateDraftUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + branchDraftCreate,) + } +);} + + + + +export const getCreateDraftMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: BranchDraftCreate}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BranchDraftCreate}, TContext> => { + +const mutationKey = ['createDraft']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BranchDraftCreate}> = (props) => { + const {data} = props ?? {}; + + return createDraft(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type CreateDraftMutationResult = NonNullable>> + export type CreateDraftMutationBody = BranchDraftCreate + export type CreateDraftMutationError = HTTPValidationError + + /** + * @summary Create Draft + */ +export const useCreateDraft = (options?: { mutation?:UseMutationOptions>, TError,{data: BranchDraftCreate}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: BranchDraftCreate}, + TContext + > => { + + const mutationOptions = getCreateDraftMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary Update Draft + */ +export type updateDraftResponse200 = { + data: BranchDraftRead + status: 200 +} + +export type updateDraftResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type updateDraftResponseSuccess = (updateDraftResponse200) & { + headers: Headers; +}; +export type updateDraftResponseError = (updateDraftResponse422) & { + headers: Headers; +}; + +export type updateDraftResponse = (updateDraftResponseSuccess | updateDraftResponseError) + +export const getUpdateDraftUrl = (branchId: string,) => { + + + + + return `/api/v1/branches/drafts/${branchId}` +} + +export const updateDraft = async (branchId: string, + branchDraftUpdate: BranchDraftUpdate, options?: RequestInit): Promise => { + + return apiFetch(getUpdateDraftUrl(branchId), + { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + branchDraftUpdate,) + } +);} + + + + +export const getUpdateDraftMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{branchId: string;data: BranchDraftUpdate}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{branchId: string;data: BranchDraftUpdate}, TContext> => { + +const mutationKey = ['updateDraft']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {branchId: string;data: BranchDraftUpdate}> = (props) => { + const {branchId,data} = props ?? {}; + + return updateDraft(branchId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateDraftMutationResult = NonNullable>> + export type UpdateDraftMutationBody = BranchDraftUpdate + export type UpdateDraftMutationError = HTTPValidationError + + /** + * @summary Update Draft + */ +export const useUpdateDraft = (options?: { mutation?:UseMutationOptions>, TError,{branchId: string;data: BranchDraftUpdate}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {branchId: string;data: BranchDraftUpdate}, + TContext + > => { + + const mutationOptions = getUpdateDraftMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary Delete Draft + */ +export type deleteDraftResponse204 = { + data: void + status: 204 +} + +export type deleteDraftResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type deleteDraftResponseSuccess = (deleteDraftResponse204) & { + headers: Headers; +}; +export type deleteDraftResponseError = (deleteDraftResponse422) & { + headers: Headers; +}; + +export type deleteDraftResponse = (deleteDraftResponseSuccess | deleteDraftResponseError) + +export const getDeleteDraftUrl = (branchId: string,) => { + + + + + return `/api/v1/branches/drafts/${branchId}` +} + +export const deleteDraft = async (branchId: string, options?: RequestInit): Promise => { + + return apiFetch(getDeleteDraftUrl(branchId), + { + ...options, + method: 'DELETE' + + + } +);} + + + + +export const getDeleteDraftMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{branchId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{branchId: string}, TContext> => { + +const mutationKey = ['deleteDraft']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {branchId: string}> = (props) => { + const {branchId} = props ?? {}; + + return deleteDraft(branchId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteDraftMutationResult = NonNullable>> + + export type DeleteDraftMutationError = HTTPValidationError + + /** + * @summary Delete Draft + */ +export const useDeleteDraft = (options?: { mutation?:UseMutationOptions>, TError,{branchId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {branchId: string}, + TContext + > => { + + const mutationOptions = getDeleteDraftMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary Reorder Drafts + */ +export type reorderDraftsResponse200 = { + data: BranchDraftRead[] + status: 200 +} + +export type reorderDraftsResponse422 = { + data: HTTPValidationError + status: 422 +} + +export type reorderDraftsResponseSuccess = (reorderDraftsResponse200) & { + headers: Headers; +}; +export type reorderDraftsResponseError = (reorderDraftsResponse422) & { + headers: Headers; +}; + +export type reorderDraftsResponse = (reorderDraftsResponseSuccess | reorderDraftsResponseError) + +export const getReorderDraftsUrl = () => { + + + + + return `/api/v1/branches/drafts/reorder` +} + +export const reorderDrafts = async (branchDraftReorder: BranchDraftReorder, options?: RequestInit): Promise => { + + return apiFetch(getReorderDraftsUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + branchDraftReorder,) + } +);} + + + + +export const getReorderDraftsMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: BranchDraftReorder}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BranchDraftReorder}, TContext> => { + +const mutationKey = ['reorderDrafts']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BranchDraftReorder}> = (props) => { + const {data} = props ?? {}; + + return reorderDrafts(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ReorderDraftsMutationResult = NonNullable>> + export type ReorderDraftsMutationBody = BranchDraftReorder + export type ReorderDraftsMutationError = HTTPValidationError + + /** + * @summary Reorder Drafts + */ +export const useReorderDrafts = (options?: { mutation?:UseMutationOptions>, TError,{data: BranchDraftReorder}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: BranchDraftReorder}, + TContext + > => { + + const mutationOptions = getReorderDraftsMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary Publish Status + */ +export type publishStatusResponse200 = { + data: BranchPublishStatus + status: 200 +} + +export type publishStatusResponseSuccess = (publishStatusResponse200) & { + headers: Headers; +}; +; + +export type publishStatusResponse = (publishStatusResponseSuccess) + +export const getPublishStatusUrl = () => { + + + + + return `/api/v1/branches/status` +} + +export const publishStatus = async ( options?: RequestInit): Promise => { + + return apiFetch(getPublishStatusUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getPublishStatusQueryKey = () => { + return [ + `/api/v1/branches/status` + ] as const; + } + + +export const getPublishStatusQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getPublishStatusQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => publishStatus({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type PublishStatusQueryResult = NonNullable>> +export type PublishStatusQueryError = unknown + + +export function usePublishStatus>, TError = unknown>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function usePublishStatus>, TError = unknown>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function usePublishStatus>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Publish Status + */ + +export function usePublishStatus>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getPublishStatusQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary Publish + */ +export type publishResponse200 = { + data: BranchRead[] + status: 200 +} + +export type publishResponseSuccess = (publishResponse200) & { + headers: Headers; +}; +; + +export type publishResponse = (publishResponseSuccess) + +export const getPublishUrl = () => { + + + + + return `/api/v1/branches/publish` +} + +export const publish = async ( options?: RequestInit): Promise => { + + return apiFetch(getPublishUrl(), + { + ...options, + method: 'POST' + + + } +);} + + + + +export const getPublishMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,void, TContext> => { + +const mutationKey = ['publish']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, void> = () => { + + + return publish(requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type PublishMutationResult = NonNullable>> + + export type PublishMutationError = unknown + + /** + * @summary Publish + */ +export const usePublish = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + + const mutationOptions = getPublishMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary Discard + */ +export type discardResponse200 = { + data: BranchDraftRead[] + status: 200 +} + +export type discardResponseSuccess = (discardResponse200) & { + headers: Headers; +}; +; + +export type discardResponse = (discardResponseSuccess) + +export const getDiscardUrl = () => { + + + + + return `/api/v1/branches/discard` +} + +export const discard = async ( options?: RequestInit): Promise => { + + return apiFetch(getDiscardUrl(), + { + ...options, + method: 'POST' + + + } +);} + + + + +export const getDiscardMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,void, TContext> => { + +const mutationKey = ['discard']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, void> = () => { + + + return discard(requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type DiscardMutationResult = NonNullable>> + + export type DiscardMutationError = unknown + + /** + * @summary Discard + */ +export const useDiscard = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + + const mutationOptions = getDiscardMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/packages/api/src/generated/model/auditAction.ts b/packages/api/src/generated/model/auditAction.ts index d68279a..f81f7b0 100644 --- a/packages/api/src/generated/model/auditAction.ts +++ b/packages/api/src/generated/model/auditAction.ts @@ -23,4 +23,10 @@ export const AuditAction = { access_requestdenied: 'access_request.denied', mediauploaded: 'media.uploaded', mediadeleted: 'media.deleted', + branch_draftcreated: 'branch_draft.created', + branch_draftupdated: 'branch_draft.updated', + branch_draftdeleted: 'branch_draft.deleted', + branch_draftreordered: 'branch_draft.reordered', + branchespublished: 'branches.published', + branchesdiscarded: 'branches.discarded', } as const; diff --git a/packages/api/src/generated/model/branchDraftCreate.ts b/packages/api/src/generated/model/branchDraftCreate.ts new file mode 100644 index 0000000..2ffa515 --- /dev/null +++ b/packages/api/src/generated/model/branchDraftCreate.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import type { BranchDraftCreateDescription } from './branchDraftCreateDescription'; + +export interface BranchDraftCreate { + /** + * @minLength 1 + * @maxLength 100 + */ + name: string; + description?: BranchDraftCreateDescription; + /** + * @minLength 1 + * @maxLength 512 + */ + icon_key: string; + /** @pattern ^#[0-9a-fA-F]{6}$ */ + color: string; + /** @minimum 0 */ + position?: number; +} diff --git a/packages/api/src/generated/model/branchDraftCreateDescription.ts b/packages/api/src/generated/model/branchDraftCreateDescription.ts new file mode 100644 index 0000000..4c0453e --- /dev/null +++ b/packages/api/src/generated/model/branchDraftCreateDescription.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftCreateDescription = string | null; diff --git a/packages/api/src/generated/model/branchDraftRead.ts b/packages/api/src/generated/model/branchDraftRead.ts new file mode 100644 index 0000000..37e13ac --- /dev/null +++ b/packages/api/src/generated/model/branchDraftRead.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import type { BranchDraftReadDescription } from './branchDraftReadDescription'; + +export interface BranchDraftRead { + id: string; + name: string; + description: BranchDraftReadDescription; + icon_key: string; + icon_url: string; + color: string; + position: number; + created_at: string; + updated_at: string; +} diff --git a/packages/api/src/generated/model/branchDraftReadDescription.ts b/packages/api/src/generated/model/branchDraftReadDescription.ts new file mode 100644 index 0000000..9e789c8 --- /dev/null +++ b/packages/api/src/generated/model/branchDraftReadDescription.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftReadDescription = string | null; diff --git a/packages/api/src/generated/model/branchDraftReorder.ts b/packages/api/src/generated/model/branchDraftReorder.ts new file mode 100644 index 0000000..3aa12b8 --- /dev/null +++ b/packages/api/src/generated/model/branchDraftReorder.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export interface BranchDraftReorder { + /** @minItems 1 */ + ids: string[]; +} diff --git a/packages/api/src/generated/model/branchDraftUpdate.ts b/packages/api/src/generated/model/branchDraftUpdate.ts new file mode 100644 index 0000000..ef1132c --- /dev/null +++ b/packages/api/src/generated/model/branchDraftUpdate.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import type { BranchDraftUpdateName } from './branchDraftUpdateName'; +import type { BranchDraftUpdateDescription } from './branchDraftUpdateDescription'; +import type { BranchDraftUpdateIconKey } from './branchDraftUpdateIconKey'; +import type { BranchDraftUpdateColor } from './branchDraftUpdateColor'; +import type { BranchDraftUpdatePosition } from './branchDraftUpdatePosition'; + +export interface BranchDraftUpdate { + name?: BranchDraftUpdateName; + description?: BranchDraftUpdateDescription; + icon_key?: BranchDraftUpdateIconKey; + color?: BranchDraftUpdateColor; + position?: BranchDraftUpdatePosition; +} diff --git a/packages/api/src/generated/model/branchDraftUpdateColor.ts b/packages/api/src/generated/model/branchDraftUpdateColor.ts new file mode 100644 index 0000000..6fed070 --- /dev/null +++ b/packages/api/src/generated/model/branchDraftUpdateColor.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftUpdateColor = string | null; diff --git a/packages/api/src/generated/model/branchDraftUpdateDescription.ts b/packages/api/src/generated/model/branchDraftUpdateDescription.ts new file mode 100644 index 0000000..0905f1a --- /dev/null +++ b/packages/api/src/generated/model/branchDraftUpdateDescription.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftUpdateDescription = string | null; diff --git a/packages/api/src/generated/model/branchDraftUpdateIconKey.ts b/packages/api/src/generated/model/branchDraftUpdateIconKey.ts new file mode 100644 index 0000000..f5636ed --- /dev/null +++ b/packages/api/src/generated/model/branchDraftUpdateIconKey.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftUpdateIconKey = string | null; diff --git a/packages/api/src/generated/model/branchDraftUpdateName.ts b/packages/api/src/generated/model/branchDraftUpdateName.ts new file mode 100644 index 0000000..695de5b --- /dev/null +++ b/packages/api/src/generated/model/branchDraftUpdateName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftUpdateName = string | null; diff --git a/packages/api/src/generated/model/branchDraftUpdatePosition.ts b/packages/api/src/generated/model/branchDraftUpdatePosition.ts new file mode 100644 index 0000000..9e1b492 --- /dev/null +++ b/packages/api/src/generated/model/branchDraftUpdatePosition.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchDraftUpdatePosition = number | null; diff --git a/packages/api/src/generated/model/branchPublishStatus.ts b/packages/api/src/generated/model/branchPublishStatus.ts new file mode 100644 index 0000000..f6674c4 --- /dev/null +++ b/packages/api/src/generated/model/branchPublishStatus.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import type { BranchPublishStatusLastPublishedAt } from './branchPublishStatusLastPublishedAt'; + +export interface BranchPublishStatus { + is_dirty: boolean; + draft_count: number; + published_count: number; + last_published_at: BranchPublishStatusLastPublishedAt; +} diff --git a/packages/api/src/generated/model/branchPublishStatusLastPublishedAt.ts b/packages/api/src/generated/model/branchPublishStatusLastPublishedAt.ts new file mode 100644 index 0000000..f866376 --- /dev/null +++ b/packages/api/src/generated/model/branchPublishStatusLastPublishedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchPublishStatusLastPublishedAt = string | null; diff --git a/packages/api/src/generated/model/branchRead.ts b/packages/api/src/generated/model/branchRead.ts new file mode 100644 index 0000000..72e16df --- /dev/null +++ b/packages/api/src/generated/model/branchRead.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import type { BranchReadDescription } from './branchReadDescription'; + +export interface BranchRead { + id: string; + name: string; + description: BranchReadDescription; + icon_url: string; + color: string; + position: number; +} diff --git a/packages/api/src/generated/model/branchReadDescription.ts b/packages/api/src/generated/model/branchReadDescription.ts new file mode 100644 index 0000000..cf7c8ad --- /dev/null +++ b/packages/api/src/generated/model/branchReadDescription.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ + +export type BranchReadDescription = string | null; diff --git a/packages/api/src/generated/model/index.ts b/packages/api/src/generated/model/index.ts index 3668b8b..6855a9c 100644 --- a/packages/api/src/generated/model/index.ts +++ b/packages/api/src/generated/model/index.ts @@ -28,6 +28,21 @@ export * from './auditLogReadAfter'; export * from './auditLogReadAfterAnyOf'; export * from './auditLogReadBefore'; export * from './auditLogReadBeforeAnyOf'; +export * from './branchDraftCreate'; +export * from './branchDraftCreateDescription'; +export * from './branchDraftRead'; +export * from './branchDraftReadDescription'; +export * from './branchDraftReorder'; +export * from './branchDraftUpdate'; +export * from './branchDraftUpdateColor'; +export * from './branchDraftUpdateDescription'; +export * from './branchDraftUpdateIconKey'; +export * from './branchDraftUpdateName'; +export * from './branchDraftUpdatePosition'; +export * from './branchPublishStatus'; +export * from './branchPublishStatusLastPublishedAt'; +export * from './branchRead'; +export * from './branchReadDescription'; export * from './cursorPageAuditLogRead'; export * from './cursorPageAuditLogReadNextCursor'; export * from './deleteMediaParams'; diff --git a/packages/api/src/generated/model/mediaPurpose.ts b/packages/api/src/generated/model/mediaPurpose.ts index 2d0652e..5bc3cce 100644 --- a/packages/api/src/generated/model/mediaPurpose.ts +++ b/packages/api/src/generated/model/mediaPurpose.ts @@ -11,4 +11,5 @@ export type MediaPurpose = typeof MediaPurpose[keyof typeof MediaPurpose]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const MediaPurpose = { avatar: 'avatar', + branch_icon: 'branch_icon', } as const; diff --git a/packages/api/src/generated/public/public.ts b/packages/api/src/generated/public/public.ts new file mode 100644 index 0000000..edab517 --- /dev/null +++ b/packages/api/src/generated/public/public.ts @@ -0,0 +1,144 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Generate Admin + * OpenAPI spec version: 0.1.0 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + BranchRead +} from '.././model'; + +import { apiFetch } from '../../http'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary List Public Branches + */ +export type listPublicBranchesResponse200 = { + data: BranchRead[] + status: 200 +} + +export type listPublicBranchesResponseSuccess = (listPublicBranchesResponse200) & { + headers: Headers; +}; +; + +export type listPublicBranchesResponse = (listPublicBranchesResponseSuccess) + +export const getListPublicBranchesUrl = () => { + + + + + return `/public/v1/branches` +} + +export const listPublicBranches = async ( options?: RequestInit): Promise => { + + return apiFetch(getListPublicBranchesUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getListPublicBranchesQueryKey = () => { + return [ + `/public/v1/branches` + ] as const; + } + + +export const getListPublicBranchesQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListPublicBranchesQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => listPublicBranches({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListPublicBranchesQueryResult = NonNullable>> +export type ListPublicBranchesQueryError = unknown + + +export function useListPublicBranches>, TError = unknown>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListPublicBranches>, TError = unknown>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListPublicBranches>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary List Public Branches + */ + +export function useListPublicBranches>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListPublicBranchesQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + +