Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c59718171c | |||
| 99d22660f9 | |||
| fcfc06cda9 | |||
| 1c8b35399c | |||
| ebf97b6f4a | |||
| fec3953009 |
@@ -115,6 +115,7 @@ Relationship: `profile` (one-to-one, cascade all+delete-orphan)
|
|||||||
| `id` | String | PK, UUID |
|
| `id` | String | PK, UUID |
|
||||||
| `group_id` | String | FK→groups.id, indexed, CASCADE |
|
| `group_id` | String | FK→groups.id, indexed, CASCADE |
|
||||||
| `user_id` | String | FK→users.id, indexed, CASCADE |
|
| `user_id` | String | FK→users.id, indexed, CASCADE |
|
||||||
|
| `is_group_admin` | Boolean | NOT NULL, default=false | grants group-admin rights (manage group categories, delete shared docs) |
|
||||||
| `joined_at` | DateTime(tz) | server_default=now() |
|
| `joined_at` | DateTime(tz) | server_default=now() |
|
||||||
|
|
||||||
Unique constraint: `(group_id, user_id)`
|
Unique constraint: `(group_id, user_id)`
|
||||||
@@ -128,6 +129,7 @@ Unique constraint: `(group_id, user_id)`
|
|||||||
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
|
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
|
||||||
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
|
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
|
||||||
| `dd6ad2f2c211` | `add_color_mode_to_users` |
|
| `dd6ad2f2c211` | `add_color_mode_to_users` |
|
||||||
|
| `e1f2a3b4c5d6` | `add_group_member_is_admin` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,6 +179,7 @@ Unique constraint: `(group_id, user_id)`
|
|||||||
| DELETE | `/api/admin/groups/{id}` | Delete (cascades memberships) |
|
| DELETE | `/api/admin/groups/{id}` | Delete (cascades memberships) |
|
||||||
| POST | `/api/admin/groups/{id}/members/{user_id}` | Add member |
|
| POST | `/api/admin/groups/{id}/members/{user_id}` | Add member |
|
||||||
| DELETE | `/api/admin/groups/{id}/members/{user_id}` | Remove member |
|
| DELETE | `/api/admin/groups/{id}/members/{user_id}` | Remove member |
|
||||||
|
| PATCH | `/api/admin/groups/{id}/members/{user_id}/admin` | Set/unset group admin role (body: `{ is_group_admin: bool }`) |
|
||||||
|
|
||||||
### Settings (`/api/settings`) — admin-only
|
### Settings (`/api/settings`) — admin-only
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add is_group_admin to group_memberships
|
||||||
|
|
||||||
|
Revision ID: e1f2a3b4c5d6
|
||||||
|
Revises: dd6ad2f2c211
|
||||||
|
Create Date: 2026-04-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "e1f2a3b4c5d6"
|
||||||
|
down_revision: Union[str, None] = "dd6ad2f2c211"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"group_memberships",
|
||||||
|
sa.Column(
|
||||||
|
"is_group_admin",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("group_memberships", "is_group_admin")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -35,6 +35,9 @@ class GroupMembership(Base):
|
|||||||
user_id: Mapped[str] = mapped_column(
|
user_id: Mapped[str] = mapped_column(
|
||||||
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
is_group_admin: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="false"
|
||||||
|
)
|
||||||
joined_at: Mapped[datetime] = mapped_column(
|
joined_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(timezone.utc),
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
|||||||
@@ -39,18 +39,26 @@ _HOP_BY_HOP = frozenset([
|
|||||||
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
|
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
|
||||||
|
|
||||||
|
|
||||||
async def _forward_headers(request: Request, user_id: str, db: AsyncSession) -> dict:
|
async def _forward_headers(
|
||||||
|
request: Request, user_id: str, is_admin: bool, db: AsyncSession
|
||||||
|
) -> dict:
|
||||||
headers = {
|
headers = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in request.headers.items()
|
for k, v in request.headers.items()
|
||||||
if k.lower() not in _HOP_BY_HOP
|
if k.lower() not in _HOP_BY_HOP
|
||||||
}
|
}
|
||||||
headers["x-user-id"] = user_id
|
headers["x-user-id"] = user_id
|
||||||
result = await db.execute(
|
headers["x-user-is-admin"] = "true" if is_admin else "false"
|
||||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
|
||||||
|
mem_result = await db.execute(
|
||||||
|
select(GroupMembership.group_id, GroupMembership.is_group_admin)
|
||||||
|
.where(GroupMembership.user_id == user_id)
|
||||||
)
|
)
|
||||||
group_ids = [row[0] for row in result.all()]
|
rows = mem_result.all()
|
||||||
|
group_ids = [row[0] for row in rows]
|
||||||
|
admin_group_ids = [row[0] for row in rows if row[1]]
|
||||||
headers["x-user-groups"] = ",".join(group_ids)
|
headers["x-user-groups"] = ",".join(group_ids)
|
||||||
|
headers["x-user-admin-groups"] = ",".join(admin_group_ids)
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +71,7 @@ async def proxy_categories(
|
|||||||
path: str = "",
|
path: str = "",
|
||||||
) -> Response:
|
) -> Response:
|
||||||
url = f"/categories/{path}" if path else "/categories"
|
url = f"/categories/{path}" if path else "/categories"
|
||||||
headers = await _forward_headers(request, str(current_user.id), db)
|
headers = await _forward_headers(request, str(current_user.id), current_user.is_superuser, db)
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -61,13 +61,17 @@ async def _forward_headers(
|
|||||||
headers["x-user-id"] = user_id
|
headers["x-user-id"] = user_id
|
||||||
headers["x-user-is-admin"] = "true" if is_admin else "false"
|
headers["x-user-is-admin"] = "true" if is_admin else "false"
|
||||||
|
|
||||||
# Inject the user's group memberships so the doc-service can evaluate
|
# Inject group memberships and group-admin status so the doc-service can
|
||||||
# group-shared document access without querying the backend DB.
|
# evaluate ownership, sharing access, and group-admin permissions.
|
||||||
result = await db.execute(
|
mem_result = await db.execute(
|
||||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
select(GroupMembership.group_id, GroupMembership.is_group_admin)
|
||||||
|
.where(GroupMembership.user_id == user_id)
|
||||||
)
|
)
|
||||||
group_ids = [row[0] for row in result.all()]
|
rows = mem_result.all()
|
||||||
|
group_ids = [row[0] for row in rows]
|
||||||
|
admin_group_ids = [row[0] for row in rows if row[1]]
|
||||||
headers["x-user-groups"] = ",".join(group_ids)
|
headers["x-user-groups"] = ",".join(group_ids)
|
||||||
|
headers["x-user-admin-groups"] = ",".join(admin_group_ids)
|
||||||
|
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from app.database import get_db
|
|||||||
from app.deps import get_current_admin
|
from app.deps import get_current_admin
|
||||||
from app.models.group import Group, GroupMembership
|
from app.models.group import Group, GroupMembership
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.group import GroupCreate, GroupDetailOut, GroupOut, GroupUpdate, GroupMemberOut
|
from app.schemas.group import GroupCreate, GroupDetailOut, GroupMemberAdminUpdate, GroupMemberOut, GroupOut, GroupUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -111,6 +111,7 @@ async def get_group(
|
|||||||
email=user.email,
|
email=user.email,
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
is_active=user.is_active,
|
is_active=user.is_active,
|
||||||
|
is_group_admin=membership.is_group_admin,
|
||||||
joined_at=membership.joined_at,
|
joined_at=membership.joined_at,
|
||||||
)
|
)
|
||||||
for membership, user in rows
|
for membership, user in rows
|
||||||
@@ -197,6 +198,26 @@ async def add_member(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{group_id}/members/{user_id}/admin", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def set_member_admin(
|
||||||
|
group_id: str,
|
||||||
|
user_id: str,
|
||||||
|
body: GroupMemberAdminUpdate,
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(GroupMembership).where(
|
||||||
|
GroupMembership.group_id == group_id, GroupMembership.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = result.scalar_one_or_none()
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=404, detail="User is not a member of this group")
|
||||||
|
membership.is_group_admin = body.is_group_admin
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def remove_member(
|
async def remove_member(
|
||||||
group_id: str,
|
group_id: str,
|
||||||
|
|||||||
@@ -39,14 +39,17 @@ async def get_my_groups(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return all groups the current user belongs to."""
|
"""Return all groups the current user belongs to, including their admin status."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Group)
|
select(Group, GroupMembership.is_group_admin)
|
||||||
.join(GroupMembership, GroupMembership.group_id == Group.id)
|
.join(GroupMembership, GroupMembership.group_id == Group.id)
|
||||||
.where(GroupMembership.user_id == current_user.id)
|
.where(GroupMembership.user_id == current_user.id)
|
||||||
.order_by(Group.name)
|
.order_by(Group.name)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return [
|
||||||
|
UserGroupOut(id=g.id, name=g.name, description=g.description, is_group_admin=is_admin)
|
||||||
|
for g, is_admin in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me/color-mode", response_model=UserOut)
|
@router.patch("/me/color-mode", response_model=UserOut)
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ class GroupMemberOut(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
full_name: str | None
|
full_name: str | None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_group_admin: bool = False
|
||||||
joined_at: datetime
|
joined_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberAdminUpdate(BaseModel):
|
||||||
|
is_group_admin: bool
|
||||||
|
|
||||||
|
|
||||||
class GroupOut(BaseModel):
|
class GroupOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class UserGroupOut(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
|
is_group_admin: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 2026-04-18 — Category scopes, group admin role, and permission model
|
||||||
|
|
||||||
|
**Timestamp:** 2026-04-18T00:00:00Z
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Introduces three category scopes (personal / group / system), a PascalCase-with-dashes naming convention, a group-admin role on group memberships, and a full permission model for who can create, rename, and delete categories and documents.
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
- `backend/alembic/versions/e1f2a3b4c5d6_add_group_member_is_admin.py` — adds `is_group_admin BOOLEAN` to `group_memberships`
|
||||||
|
- `features/doc-service/alembic/versions/0005_add_share_can_delete.py` — adds `can_delete BOOLEAN` to `document_shares` (backfill from feat/document-delete-permissions)
|
||||||
|
- `features/doc-service/alembic/versions/0006_add_category_scope.py` — adds `scope VARCHAR(16)` and `group_id VARCHAR` to `document_categories`; data-migrates watch categories to scope='system'
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `backend/app/models/group.py` — added `is_group_admin` to `GroupMembership`
|
||||||
|
- `backend/app/schemas/group.py` — added `is_group_admin` to `GroupMemberOut`; new `GroupMemberAdminUpdate`
|
||||||
|
- `backend/app/schemas/user.py` — added `is_group_admin` to `UserGroupOut`
|
||||||
|
- `backend/app/routers/users.py` — `get_my_groups` now joins `GroupMembership` to include `is_group_admin`
|
||||||
|
- `backend/app/routers/groups.py` — `get_group` includes `is_group_admin`; new `PATCH /{id}/members/{user_id}/admin` endpoint
|
||||||
|
- `backend/app/routers/categories_proxy.py` — injects `x-user-is-admin` and `x-user-admin-groups` headers
|
||||||
|
- `backend/app/routers/documents_proxy.py` — injects `x-user-admin-groups` header (was already injecting `x-user-is-admin`)
|
||||||
|
- `features/doc-service/app/models/category.py` — added `scope`, `group_id` columns
|
||||||
|
- `features/doc-service/app/schemas/category.py` — `CategoryOut` includes `scope`/`group_id`; `CategoryCreate` accepts `group_id`
|
||||||
|
- `features/doc-service/app/deps.py` — added `get_user_is_admin`, `get_user_admin_groups`
|
||||||
|
- `features/doc-service/app/routers/categories.py` — full rewrite: name validation regex, scope-based list/create, `_check_can_manage_cat` permission helper, scope-aware rename/delete
|
||||||
|
- `features/doc-service/app/routers/documents.py` — `delete_document` enforces is_admin/can_delete/group-admin hierarchy; `remove_category` requires doc ownership; `assign_category` accepts group/system categories
|
||||||
|
- `frontend/src/api/client.ts` — `CategoryOut` gains `scope`/`group_id`; `createCategory` accepts optional `groupId`; `UserGroupOut`/`GroupMemberOut` gain `is_group_admin`; new `adminSetGroupMemberAdmin()`; `ApiError` exported
|
||||||
|
- `frontend/src/components/ManageCategoriesDialog.tsx` — categories grouped by scope; lock icons for unmanageable categories; rename/delete gated by scope permissions; inline rename error display
|
||||||
|
- `frontend/src/components/SourcePanel.tsx` — categories shown in sections (Mine / Group name / System); scope picker on new category form; client-side name validation
|
||||||
|
- `frontend/src/pages/AdminGroupsPage.tsx` — group admin checkbox column in members table
|
||||||
|
- `backend/CLAUDE.md` — updated `group_memberships` model, migration chain, endpoints
|
||||||
|
- `features/doc-service/CLAUDE.md` — updated `document_categories` model, `document_shares` model, migration chain, deps note
|
||||||
@@ -23,7 +23,7 @@ features/doc-service/
|
|||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py ← FastAPI, lifespan (file watcher start/stop)
|
│ ├── main.py ← FastAPI, lifespan (file watcher start/stop)
|
||||||
│ ├── database.py ← Same PostgreSQL instance as backend
|
│ ├── database.py ← Same PostgreSQL instance as backend
|
||||||
│ ├── deps.py ← get_user_id (x-user-id), get_user_groups (x-user-groups)
|
│ ├── deps.py ← get_user_id, get_user_groups, get_user_is_admin, get_user_admin_groups (injected headers)
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── document.py ← Document model
|
│ │ ├── document.py ← Document model
|
||||||
│ │ ├── category.py ← DocumentCategory model
|
│ │ ├── category.py ← DocumentCategory model
|
||||||
@@ -78,12 +78,14 @@ features/doc-service/
|
|||||||
|
|
||||||
### `document_categories`
|
### `document_categories`
|
||||||
|
|
||||||
| Column | Type | Constraints |
|
| Column | Type | Constraints | Notes |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|-------|
|
||||||
| `id` | String | PK, UUID |
|
| `id` | String | PK, UUID | |
|
||||||
| `user_id` | String | indexed |
|
| `user_id` | String | indexed | owner; "watch" for system categories |
|
||||||
| `name` | String(128) | NOT NULL |
|
| `name` | String(128) | NOT NULL | PascalCase-with-dashes convention enforced on create/rename |
|
||||||
| `created_at` | DateTime(tz) | server_default=now() |
|
| `scope` | String(16) | NOT NULL, default="personal" | "personal" / "group" / "system" |
|
||||||
|
| `group_id` | String | nullable, indexed | set when scope="group" |
|
||||||
|
| `created_at` | DateTime(tz) | server_default=now() | |
|
||||||
|
|
||||||
### `document_category_assignments` (composite PK)
|
### `document_category_assignments` (composite PK)
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ features/doc-service/
|
|||||||
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
|
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
|
||||||
| `group_id` | String | indexed, NOT NULL | group from backend |
|
| `group_id` | String | indexed, NOT NULL | group from backend |
|
||||||
| `shared_by_user_id` | String | NOT NULL | owner who shared |
|
| `shared_by_user_id` | String | NOT NULL | owner who shared |
|
||||||
| `can_delete` | Boolean | NOT NULL, default=false | whether group members may delete the doc |
|
| `can_delete` | Boolean | NOT NULL, default=false | allows group members to delete the doc |
|
||||||
| `created_at` | DateTime(tz) | server_default=now() | |
|
| `created_at` | DateTime(tz) | server_default=now() | |
|
||||||
|
|
||||||
Unique constraint: `(document_id, group_id)`
|
Unique constraint: `(document_id, group_id)`
|
||||||
@@ -114,6 +116,8 @@ Unique constraint: `(document_id, group_id)`
|
|||||||
| `0003` | `add_watch_columns` |
|
| `0003` | `add_watch_columns` |
|
||||||
| `0004` | `add_document_shares` |
|
| `0004` | `add_document_shares` |
|
||||||
| `0005` | `add_share_can_delete` |
|
| `0005` | `add_share_can_delete` |
|
||||||
|
| `0006` | `add_category_scope` |
|
||||||
|
| `0007` | `capitalize_system_category_names` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""add scope and group_id to document_categories
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-04-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0006"
|
||||||
|
down_revision: Union[str, None] = "0005"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"document_categories",
|
||||||
|
sa.Column(
|
||||||
|
"scope",
|
||||||
|
sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
server_default="personal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"document_categories",
|
||||||
|
sa.Column("group_id", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_document_categories_group_id", "document_categories", ["group_id"])
|
||||||
|
|
||||||
|
# Migrate existing watch-owned categories to system scope
|
||||||
|
op.execute("UPDATE document_categories SET scope = 'system' WHERE user_id = 'watch'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_document_categories_group_id", table_name="document_categories")
|
||||||
|
op.drop_column("document_categories", "group_id")
|
||||||
|
op.drop_column("document_categories", "scope")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""capitalize existing system category names to PascalCase-with-dashes
|
||||||
|
|
||||||
|
Revision ID: 0007
|
||||||
|
Revises: 0006
|
||||||
|
Create Date: 2026-04-18
|
||||||
|
|
||||||
|
Converts names like "invoices" → "Invoices", "vendor-invoices" → "Vendor-Invoices"
|
||||||
|
for all categories with scope='system' (watch-ingested).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0007"
|
||||||
|
down_revision: Union[str, None] = "0006"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_pascal(name: str) -> str:
|
||||||
|
return "-".join(p.capitalize() for p in re.split(r"[-_\s]+", name) if p)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(
|
||||||
|
text("SELECT id, name FROM document_categories WHERE scope = 'system'")
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
new_name = _to_pascal(row.name)
|
||||||
|
if new_name != row.name:
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE document_categories SET name = :name WHERE id = :id"),
|
||||||
|
{"name": new_name[:128], "id": row.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass # names before migration are unknown; downgrade is a no-op
|
||||||
@@ -25,7 +25,18 @@ async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
|
|||||||
|
|
||||||
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
|
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
|
||||||
"""
|
"""
|
||||||
Extract the admin flag injected by the main backend proxy.
|
Extract the superuser flag injected by the main backend proxy.
|
||||||
Returns True only if the header value is exactly "true" (lowercase).
|
Returns True only when the header value is exactly "true".
|
||||||
"""
|
"""
|
||||||
return x_user_is_admin.lower() == "true"
|
return x_user_is_admin.lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_admin_groups(x_user_admin_groups: str = Header(default="")) -> list[str]:
|
||||||
|
"""
|
||||||
|
Extract the group IDs for which the current user is a group admin.
|
||||||
|
Injected by the main backend proxy from GroupMembership.is_group_admin.
|
||||||
|
Returns an empty list if absent or empty.
|
||||||
|
"""
|
||||||
|
if not x_user_admin_groups:
|
||||||
|
return []
|
||||||
|
return [g.strip() for g in x_user_admin_groups.split(",") if g.strip()]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class DocumentCategory(Base):
|
|||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="personal", server_default="personal")
|
||||||
|
group_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import difflib
|
import difflib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal, get_db
|
from app.database import AsyncSessionLocal, get_db
|
||||||
from app.deps import get_user_id
|
from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
|
||||||
from app.models.category import DocumentCategory
|
from app.models.category import DocumentCategory
|
||||||
from app.models.category_assignment import CategoryAssignment
|
from app.models.category_assignment import CategoryAssignment
|
||||||
from app.models.document import Document
|
from app.models.document import Document
|
||||||
@@ -22,14 +23,31 @@ _WATCH_USER_ID = "watch"
|
|||||||
|
|
||||||
_SIMILARITY_THRESHOLD = 0.4
|
_SIMILARITY_THRESHOLD = 0.4
|
||||||
|
|
||||||
|
# PascalCase-with-dashes: each word starts with a capital, words joined by '-'
|
||||||
|
# Valid: Invoices, Vendor-Invoices, Q1-Reports
|
||||||
|
# Invalid: invoices, Invoice Reports, Invoice-reports
|
||||||
|
_NAME_RE = re.compile(r'^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$')
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> None:
|
||||||
|
if not _NAME_RE.match(name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
"Category name must start with a capital letter. "
|
||||||
|
"Multiple words are joined with dashes, each starting with a capital "
|
||||||
|
"(e.g. Invoices, Vendor-Invoices, Q1-Reports)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _name_similarity(a: str, b: str) -> float:
|
def _name_similarity(a: str, b: str) -> float:
|
||||||
"""Return similarity score (0–1) between two category names."""
|
"""Return similarity score (0–1) between two category names."""
|
||||||
a_low = a.lower()
|
a_low = a.lower()
|
||||||
b_low = b.lower()
|
b_low = b.lower()
|
||||||
# Word overlap is a strong signal
|
# Word overlap is a strong signal
|
||||||
a_words = set(a_low.split())
|
a_words = set(a_low.split("-"))
|
||||||
b_words = set(b_low.split())
|
b_words = set(b_low.split("-"))
|
||||||
if a_words & b_words:
|
if a_words & b_words:
|
||||||
return 0.9
|
return 0.9
|
||||||
# Fallback: character sequence ratio
|
# Fallback: character sequence ratio
|
||||||
@@ -81,15 +99,44 @@ async def _reanalyze_documents_for_new_category(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_can_manage_cat(
|
||||||
|
cat: DocumentCategory,
|
||||||
|
user_id: str,
|
||||||
|
is_admin: bool,
|
||||||
|
user_admin_groups: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Raise 403/404 if the current user may not rename or delete this category."""
|
||||||
|
if is_admin:
|
||||||
|
return # superuser can manage anything
|
||||||
|
if cat.scope == "system":
|
||||||
|
raise HTTPException(status_code=403, detail="Only admins can modify system categories")
|
||||||
|
if cat.scope == "personal" and cat.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
if cat.scope == "group" and cat.group_id not in user_admin_groups:
|
||||||
|
raise HTTPException(status_code=403, detail="Only group admins can modify group categories")
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[CategoryOut])
|
@router.get("", response_model=list[CategoryOut])
|
||||||
async def list_categories(
|
async def list_categories(
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
|
user_groups: list[str] = Depends(get_user_groups),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> list[DocumentCategory]:
|
) -> list[DocumentCategory]:
|
||||||
# Include watch-ingested categories so they appear in the sidebar/filter
|
"""
|
||||||
|
Return all categories visible to the current user:
|
||||||
|
- personal (owned by the user)
|
||||||
|
- group (any group the user belongs to)
|
||||||
|
- system (watch-ingested, scope='system')
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(DocumentCategory)
|
select(DocumentCategory)
|
||||||
.where(or_(DocumentCategory.user_id == user_id, DocumentCategory.user_id == _WATCH_USER_ID))
|
.where(
|
||||||
|
or_(
|
||||||
|
DocumentCategory.user_id == user_id, # personal
|
||||||
|
DocumentCategory.group_id.in_(user_groups), # group
|
||||||
|
DocumentCategory.scope == "system", # system
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(DocumentCategory.name)
|
.order_by(DocumentCategory.name)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@@ -100,18 +147,32 @@ async def create_category(
|
|||||||
body: CategoryCreate,
|
body: CategoryCreate,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
|
user_groups: list[str] = Depends(get_user_groups),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> DocumentCategory:
|
) -> DocumentCategory:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=422, detail="Category name cannot be empty")
|
raise HTTPException(status_code=422, detail="Category name cannot be empty")
|
||||||
|
_validate_name(name)
|
||||||
|
|
||||||
|
if body.group_id:
|
||||||
|
# User must be a member of the target group
|
||||||
|
if body.group_id not in user_groups:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a member of that group")
|
||||||
|
cat = DocumentCategory(
|
||||||
|
user_id=user_id,
|
||||||
|
name=name[:128],
|
||||||
|
scope="group",
|
||||||
|
group_id=body.group_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cat = DocumentCategory(user_id=user_id, name=name[:128], scope="personal")
|
||||||
|
|
||||||
cat = DocumentCategory(user_id=user_id, name=name[:128])
|
|
||||||
db.add(cat)
|
db.add(cat)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(cat)
|
await db.refresh(cat)
|
||||||
|
|
||||||
# Find existing categories with similar names
|
# Find existing personal categories with similar names for background AI reanalysis
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(DocumentCategory)
|
select(DocumentCategory)
|
||||||
.where(DocumentCategory.user_id == user_id)
|
.where(DocumentCategory.user_id == user_id)
|
||||||
@@ -140,12 +201,18 @@ async def rename_category(
|
|||||||
cat_id: str,
|
cat_id: str,
|
||||||
body: CategoryUpdate,
|
body: CategoryUpdate,
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
|
is_admin: bool = Depends(get_user_is_admin),
|
||||||
|
user_admin_groups: list[str] = Depends(get_user_admin_groups),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> DocumentCategory:
|
) -> DocumentCategory:
|
||||||
cat = await _get_user_cat(cat_id, user_id, db)
|
cat = await _fetch_visible_cat(cat_id, user_id, db)
|
||||||
|
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
|
||||||
|
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=422, detail="Category name cannot be empty")
|
raise HTTPException(status_code=422, detail="Category name cannot be empty")
|
||||||
|
_validate_name(name)
|
||||||
|
|
||||||
cat.name = name[:128]
|
cat.name = name[:128]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(cat)
|
await db.refresh(cat)
|
||||||
@@ -156,19 +223,20 @@ async def rename_category(
|
|||||||
async def delete_category(
|
async def delete_category(
|
||||||
cat_id: str,
|
cat_id: str,
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
|
is_admin: bool = Depends(get_user_is_admin),
|
||||||
|
user_admin_groups: list[str] = Depends(get_user_admin_groups),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
cat = await _get_user_cat(cat_id, user_id, db)
|
cat = await _fetch_visible_cat(cat_id, user_id, db)
|
||||||
|
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
|
||||||
await db.delete(cat)
|
await db.delete(cat)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
|
async def _fetch_visible_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
|
||||||
|
"""Fetch a category that the user can see (personal/group/system). 404 if absent."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(DocumentCategory).where(
|
select(DocumentCategory).where(DocumentCategory.id == cat_id)
|
||||||
DocumentCategory.id == cat_id,
|
|
||||||
DocumentCategory.user_id == user_id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
cat = result.scalar_one_or_none()
|
cat = result.scalar_one_or_none()
|
||||||
if cat is None:
|
if cat is None:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal, get_db
|
from app.database import AsyncSessionLocal, get_db
|
||||||
from app.deps import get_user_groups, get_user_id, get_user_is_admin
|
from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
|
||||||
from app.models.category import DocumentCategory
|
from app.models.category import DocumentCategory
|
||||||
from app.models.category_assignment import CategoryAssignment
|
from app.models.category_assignment import CategoryAssignment
|
||||||
from app.models.document import Document
|
from app.models.document import Document
|
||||||
@@ -521,14 +521,24 @@ async def reprocess_document(
|
|||||||
async def delete_document(
|
async def delete_document(
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
user_groups: list[str] = Depends(get_user_groups),
|
|
||||||
is_admin: bool = Depends(get_user_is_admin),
|
is_admin: bool = Depends(get_user_is_admin),
|
||||||
|
user_groups: list[str] = Depends(get_user_groups),
|
||||||
|
user_admin_groups: list[str] = Depends(get_user_admin_groups),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Fetch the document (owner, watch, or shared with user's groups)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Document).where(
|
select(Document).where(
|
||||||
Document.id == doc_id,
|
Document.id == doc_id,
|
||||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
or_(
|
||||||
|
Document.user_id == user_id,
|
||||||
|
Document.user_id == _WATCH_USER_ID,
|
||||||
|
Document.id.in_(
|
||||||
|
select(DocumentShare.document_id).where(
|
||||||
|
DocumentShare.group_id.in_(user_groups)
|
||||||
|
)
|
||||||
|
) if user_groups else False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
doc = result.scalar_one_or_none()
|
doc = result.scalar_one_or_none()
|
||||||
@@ -536,10 +546,33 @@ async def delete_document(
|
|||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
is_owner = doc.user_id == user_id
|
is_owner = doc.user_id == user_id
|
||||||
|
|
||||||
if not is_owner and not is_admin:
|
if not is_owner and not is_admin:
|
||||||
can_delete_via_group = bool(await _get_deletable_doc_ids([doc_id], user_groups, db))
|
# Check: shared with a group where the user has can_delete=True
|
||||||
if not can_delete_via_group:
|
can_delete_via_share = False
|
||||||
raise HTTPException(status_code=403, detail="Delete not permitted")
|
if user_groups:
|
||||||
|
share_result = await db.execute(
|
||||||
|
select(DocumentShare).where(
|
||||||
|
DocumentShare.document_id == doc_id,
|
||||||
|
DocumentShare.group_id.in_(user_groups),
|
||||||
|
DocumentShare.can_delete.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
can_delete_via_share = share_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
# Check: user is a group admin for a group the doc is shared with
|
||||||
|
can_delete_as_group_admin = False
|
||||||
|
if user_admin_groups:
|
||||||
|
admin_share_result = await db.execute(
|
||||||
|
select(DocumentShare).where(
|
||||||
|
DocumentShare.document_id == doc_id,
|
||||||
|
DocumentShare.group_id.in_(user_admin_groups),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
can_delete_as_group_admin = admin_share_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
if not can_delete_via_share and not can_delete_as_group_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not allowed to delete this document")
|
||||||
|
|
||||||
delete_file(doc.file_path)
|
delete_file(doc.file_path)
|
||||||
await db.delete(doc)
|
await db.delete(doc)
|
||||||
@@ -591,6 +624,7 @@ async def assign_category(
|
|||||||
doc_id: str,
|
doc_id: str,
|
||||||
cat_id: str,
|
cat_id: str,
|
||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
|
user_groups: list[str] = Depends(get_user_groups),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
doc_result = await db.execute(
|
doc_result = await db.execute(
|
||||||
@@ -602,9 +636,15 @@ async def assign_category(
|
|||||||
if doc_result.scalar_one_or_none() is None:
|
if doc_result.scalar_one_or_none() is None:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
|
# Accept personal, group (user is a member), or system categories
|
||||||
cat_result = await db.execute(
|
cat_result = await db.execute(
|
||||||
select(DocumentCategory).where(
|
select(DocumentCategory).where(
|
||||||
DocumentCategory.id == cat_id, DocumentCategory.user_id == user_id
|
DocumentCategory.id == cat_id,
|
||||||
|
or_(
|
||||||
|
DocumentCategory.user_id == user_id,
|
||||||
|
DocumentCategory.group_id.in_(user_groups) if user_groups else False,
|
||||||
|
DocumentCategory.scope == "system",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if cat_result.scalar_one_or_none() is None:
|
if cat_result.scalar_one_or_none() is None:
|
||||||
@@ -628,6 +668,13 @@ async def remove_category(
|
|||||||
user_id: str = Depends(get_user_id),
|
user_id: str = Depends(get_user_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Only the document owner may remove a category assignment
|
||||||
|
doc_result = await db.execute(
|
||||||
|
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||||
|
)
|
||||||
|
if doc_result.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Only the document owner can remove category assignments")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CategoryAssignment).where(
|
select(CategoryAssignment).where(
|
||||||
CategoryAssignment.document_id == doc_id,
|
CategoryAssignment.document_id == doc_id,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class CategoryOut(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
name: str
|
name: str
|
||||||
|
scope: str = "personal"
|
||||||
|
group_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
@@ -14,6 +16,7 @@ class CategoryOut(BaseModel):
|
|||||||
|
|
||||||
class CategoryCreate(BaseModel):
|
class CategoryCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
group_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ Key design decisions:
|
|||||||
- Watch documents use user_id="watch" as a sentinel so they are visible to
|
- Watch documents use user_id="watch" as a sentinel so they are visible to
|
||||||
all authenticated users in the document list.
|
all authenticated users in the document list.
|
||||||
- Subfolder names map to categories: a file at invoices/bill.pdf is assigned
|
- Subfolder names map to categories: a file at invoices/bill.pdf is assigned
|
||||||
to a "invoices" category (auto-created if needed).
|
to an "Invoices" category (auto-created if needed; folder name is converted
|
||||||
|
to PascalCase-with-dashes: "vendor-invoices" → "Vendor-Invoices").
|
||||||
- Suggestions: if ai_folder_suggestion or ai_rename_suggestion are enabled,
|
- Suggestions: if ai_folder_suggestion or ai_rename_suggestion are enabled,
|
||||||
the relevant fields are set on the document after AI processing so users
|
the relevant fields are set on the document after AI processing so users
|
||||||
can confirm/reject from the UI.
|
can confirm/reject from the UI.
|
||||||
@@ -21,6 +22,7 @@ Key design decisions:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -66,7 +68,10 @@ async def ingest_file(path_str: str, watch_root: Path, config: dict) -> None:
|
|||||||
# Determine category from the first subfolder component
|
# Determine category from the first subfolder component
|
||||||
try:
|
try:
|
||||||
rel = path.relative_to(watch_root)
|
rel = path.relative_to(watch_root)
|
||||||
folder_name = rel.parts[0] if len(rel.parts) > 1 else None
|
folder_name = (
|
||||||
|
"-".join(p.capitalize() for p in re.split(r"[-_\s]+", rel.parts[0]) if p)
|
||||||
|
if len(rel.parts) > 1 else None
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
folder_name = None
|
folder_name = None
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const BASE = "/api";
|
|||||||
// Core fetch wrapper
|
// Core fetch wrapper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
|
||||||
@@ -199,6 +199,8 @@ export type DocumentStatus = "pending" | "processing" | "done" | "failed";
|
|||||||
export interface CategoryOut {
|
export interface CategoryOut {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
scope: "personal" | "group" | "system";
|
||||||
|
group_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentOut {
|
export interface DocumentOut {
|
||||||
@@ -334,8 +336,8 @@ export const removeCategory = (docId: string, catId: string) =>
|
|||||||
|
|
||||||
export const listCategories = () => api.get<CategoryOut[]>("/documents/categories");
|
export const listCategories = () => api.get<CategoryOut[]>("/documents/categories");
|
||||||
|
|
||||||
export const createCategory = (name: string) =>
|
export const createCategory = (name: string, groupId?: string) =>
|
||||||
api.post<CategoryOut>("/documents/categories", { name });
|
api.post<CategoryOut>("/documents/categories", { name, group_id: groupId ?? null });
|
||||||
|
|
||||||
export const renameCategory = (id: string, name: string) =>
|
export const renameCategory = (id: string, name: string) =>
|
||||||
api.patch<CategoryOut>(`/documents/categories/${id}`, { name });
|
api.patch<CategoryOut>(`/documents/categories/${id}`, { name });
|
||||||
@@ -433,6 +435,7 @@ export interface UserGroupOut {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
is_group_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups");
|
export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups");
|
||||||
@@ -455,6 +458,7 @@ export interface GroupMemberOut {
|
|||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
joined_at: string;
|
joined_at: string;
|
||||||
|
is_group_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupDetailOut extends GroupOut {
|
export interface GroupDetailOut extends GroupOut {
|
||||||
@@ -491,6 +495,9 @@ export const adminAddGroupMember = (groupId: string, userId: string) =>
|
|||||||
export const adminRemoveGroupMember = (groupId: string, userId: string) =>
|
export const adminRemoveGroupMember = (groupId: string, userId: string) =>
|
||||||
api.delete(`/admin/groups/${groupId}/members/${userId}`);
|
api.delete(`/admin/groups/${groupId}/members/${userId}`);
|
||||||
|
|
||||||
|
export const adminSetGroupMemberAdmin = (groupId: string, userId: string, isAdmin: boolean) =>
|
||||||
|
api.patch(`/admin/groups/${groupId}/members/${userId}/admin`, { is_group_admin: isAdmin });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Services
|
// Services
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { X, Pencil, Trash2, Check } from "lucide-react";
|
import { X, Pencil, Trash2, Check, Lock } from "lucide-react";
|
||||||
import { listCategories, renameCategory, deleteCategory } from "@/api/client";
|
import {
|
||||||
|
listCategories,
|
||||||
|
renameCategory,
|
||||||
|
deleteCategory,
|
||||||
|
getMyGroups,
|
||||||
|
getMe,
|
||||||
|
type CategoryOut,
|
||||||
|
ApiError,
|
||||||
|
} from "@/api/client";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -11,13 +19,26 @@ interface Props {
|
|||||||
|
|
||||||
export default function ManageCategoriesDialog({ onClose }: Props) {
|
export default function ManageCategoriesDialog({ onClose }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
queryKey: ["categories"],
|
queryKey: ["categories"],
|
||||||
queryFn: listCategories,
|
queryFn: listCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: myGroups = [] } = useQuery({
|
||||||
|
queryKey: ["my-groups"],
|
||||||
|
queryFn: getMyGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||||
|
const isSuperuser = me?.is_admin ?? false;
|
||||||
|
|
||||||
|
// Set of group IDs for which the current user is a group admin
|
||||||
|
const adminGroupIds = new Set(myGroups.filter((g) => g.is_group_admin).map((g) => g.id));
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -30,6 +51,10 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
setEditError(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setEditError(err instanceof ApiError ? err.message : "Rename failed");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,24 +63,136 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function canManage(cat: CategoryOut): boolean {
|
||||||
|
if (isSuperuser) return true;
|
||||||
|
if (cat.scope === "personal") return true;
|
||||||
|
if (cat.scope === "group") return cat.group_id != null && adminGroupIds.has(cat.group_id);
|
||||||
|
return false; // system — non-admin cannot manage
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
: categories;
|
: categories;
|
||||||
|
|
||||||
|
// Group by scope
|
||||||
|
const personal = filtered.filter((c) => c.scope === "personal");
|
||||||
|
const system = filtered.filter((c) => c.scope === "system");
|
||||||
|
|
||||||
|
// Group-scoped categories grouped by group_id
|
||||||
|
const groupCats = filtered.filter((c) => c.scope === "group");
|
||||||
|
const groupMap = new Map<string, { name: string; cats: CategoryOut[] }>();
|
||||||
|
for (const cat of groupCats) {
|
||||||
|
if (!cat.group_id) continue;
|
||||||
|
if (!groupMap.has(cat.group_id)) {
|
||||||
|
const grp = myGroups.find((g) => g.id === cat.group_id);
|
||||||
|
groupMap.set(cat.group_id, { name: grp?.name ?? cat.group_id, cats: [] });
|
||||||
|
}
|
||||||
|
groupMap.get(cat.group_id)!.cats.push(cat);
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit(id: string, name: string) {
|
function startEdit(id: string, name: string) {
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
setEditValue(name);
|
setEditValue(name);
|
||||||
|
setEditError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitEdit(id: string) {
|
function submitEdit(id: string) {
|
||||||
const name = editValue.trim();
|
const name = editValue.trim();
|
||||||
if (name && name !== categories.find((c) => c.id === id)?.name) {
|
if (!name) return;
|
||||||
renameMut.mutate({ id, name });
|
if (name === categories.find((c) => c.id === id)?.name) {
|
||||||
} else {
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
renameMut.mutate({ id, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCat(cat: CategoryOut) {
|
||||||
|
const manageable = canManage(cat);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
|
||||||
|
>
|
||||||
|
{editingId === cat.id ? (
|
||||||
|
<div className="flex flex-col flex-1 gap-1">
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={editInputRef}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => { setEditValue(e.target.value); setEditError(null); }}
|
||||||
|
className="h-7 text-sm flex-1"
|
||||||
|
disabled={renameMut.isPending}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") { setEditingId(null); setEditError(null); } }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!editValue.trim() || renameMut.isPending}
|
||||||
|
className="text-primary hover:text-primary/80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditingId(null); setEditError(null); }}
|
||||||
|
className="text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{editError && (
|
||||||
|
<p className="text-xs text-red-500 pl-0.5">{editError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!manageable && (
|
||||||
|
<Lock className="h-3.5 w-3.5 text-muted flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-sm truncate">{cat.name}</span>
|
||||||
|
{manageable && (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(cat.id, cat.name)}
|
||||||
|
className="text-muted hover:text-foreground transition-colors p-0.5"
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
|
||||||
|
deleteMut.mutate(cat.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(title: string, cats: CategoryOut[]) {
|
||||||
|
if (cats.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1">{title}</p>
|
||||||
|
{cats.map(renderCat)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAny = filtered.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70"
|
||||||
@@ -83,73 +220,17 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1 min-h-0">
|
<div className="flex-1 overflow-y-auto px-5 py-3 min-h-0">
|
||||||
{filtered.length === 0 && (
|
{!hasAny && (
|
||||||
<p className="text-sm text-muted py-4 text-center">
|
<p className="text-sm text-muted py-4 text-center">
|
||||||
{search ? "No categories match" : "No categories yet"}
|
{search ? "No categories match" : "No categories yet"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filtered.map((cat) => (
|
{renderSection("My Categories", personal)}
|
||||||
<div
|
{Array.from(groupMap.entries()).map(([, { name, cats }]) =>
|
||||||
key={cat.id}
|
renderSection(`Group: ${name}`, cats)
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
|
|
||||||
>
|
|
||||||
{editingId === cat.id ? (
|
|
||||||
<form
|
|
||||||
className="flex items-center gap-2 flex-1"
|
|
||||||
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
ref={editInputRef}
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
className="h-7 text-sm flex-1"
|
|
||||||
disabled={renameMut.isPending}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingId(null); }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!editValue.trim() || renameMut.isPending}
|
|
||||||
className="text-primary hover:text-primary/80 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
className="text-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 text-sm truncate">{cat.name}</span>
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => startEdit(cat.id, cat.name)}
|
|
||||||
className="text-muted hover:text-foreground transition-colors p-0.5"
|
|
||||||
title="Rename"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
|
|
||||||
deleteMut.mutate(cat.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteMut.isPending}
|
|
||||||
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{renderSection("System", system)}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import { useState, useRef, useEffect } from "react";
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
|
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
|
||||||
import { listCategories, createCategory, CategoryOut } from "@/api/client";
|
import {
|
||||||
|
listCategories,
|
||||||
|
createCategory,
|
||||||
|
getMyGroups,
|
||||||
|
type CategoryOut,
|
||||||
|
ApiError,
|
||||||
|
} from "@/api/client";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
|
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
|
||||||
|
|
||||||
|
// PascalCase-with-dashes naming convention
|
||||||
|
const NAME_RE = /^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$/;
|
||||||
|
|
||||||
export default function SourcePanel() {
|
export default function SourcePanel() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -16,6 +25,8 @@ export default function SourcePanel() {
|
|||||||
const [catSearch, setCatSearch] = useState("");
|
const [catSearch, setCatSearch] = useState("");
|
||||||
const [addingCat, setAddingCat] = useState(false);
|
const [addingCat, setAddingCat] = useState(false);
|
||||||
const [newCatName, setNewCatName] = useState("");
|
const [newCatName, setNewCatName] = useState("");
|
||||||
|
const [newCatGroupId, setNewCatGroupId] = useState<string>("");
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
const [manageOpen, setManageOpen] = useState(false);
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
const addInputRef = useRef<HTMLInputElement>(null);
|
const addInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -24,12 +35,23 @@ export default function SourcePanel() {
|
|||||||
queryFn: listCategories,
|
queryFn: listCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: myGroups = [] } = useQuery({
|
||||||
|
queryKey: ["my-groups"],
|
||||||
|
queryFn: getMyGroups,
|
||||||
|
});
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: createCategory,
|
mutationFn: ({ name, groupId }: { name: string; groupId?: string }) =>
|
||||||
|
createCategory(name, groupId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
setNewCatName("");
|
setNewCatName("");
|
||||||
|
setNewCatGroupId("");
|
||||||
setAddingCat(false);
|
setAddingCat(false);
|
||||||
|
setNameError(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setNameError(err instanceof ApiError ? err.message : "Failed to create category");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +63,19 @@ export default function SourcePanel() {
|
|||||||
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
||||||
: categories;
|
: categories;
|
||||||
|
|
||||||
|
// Split by scope for display
|
||||||
|
const personalCats = filteredCats.filter((c) => c.scope === "personal");
|
||||||
|
const systemCats = filteredCats.filter((c) => c.scope === "system");
|
||||||
|
const groupCatMap = new Map<string, { name: string; cats: CategoryOut[] }>();
|
||||||
|
for (const cat of filteredCats.filter((c) => c.scope === "group")) {
|
||||||
|
if (!cat.group_id) continue;
|
||||||
|
if (!groupCatMap.has(cat.group_id)) {
|
||||||
|
const grp = myGroups.find((g) => g.id === cat.group_id);
|
||||||
|
groupCatMap.set(cat.group_id, { name: grp?.name ?? cat.group_id, cats: [] });
|
||||||
|
}
|
||||||
|
groupCatMap.get(cat.group_id)!.cats.push(cat);
|
||||||
|
}
|
||||||
|
|
||||||
function setView(view: string) {
|
function setView(view: string) {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev);
|
||||||
@@ -61,6 +96,20 @@ export default function SourcePanel() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newCatName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (!NAME_RE.test(name)) {
|
||||||
|
setNameError(
|
||||||
|
"Must start with a capital letter. Join multiple words with dashes, each capitalised (e.g. Vendor-Invoices)."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNameError(null);
|
||||||
|
createMut.mutate({ name, groupId: newCatGroupId || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
const viewItemClass = (active: boolean) =>
|
const viewItemClass = (active: boolean) =>
|
||||||
cn(
|
cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
|
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
|
||||||
@@ -77,6 +126,32 @@ export default function SourcePanel() {
|
|||||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function renderCatSection(label: string, cats: CategoryOut[]) {
|
||||||
|
if (cats.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-1">
|
||||||
|
<p className="text-[10px] font-semibold text-muted uppercase tracking-wider px-2 pt-1.5 pb-0.5">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{cats.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
className={catItemClass(currentCategoryId === cat.id)}
|
||||||
|
onClick={() => selectCategory(cat)}
|
||||||
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{cat.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSections =
|
||||||
|
personalCats.length > 0 ||
|
||||||
|
systemCats.length > 0 ||
|
||||||
|
groupCatMap.size > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
|
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
|
||||||
@@ -132,18 +207,13 @@ export default function SourcePanel() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-0.5 min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{filteredCats.map((cat) => (
|
{renderCatSection("Mine", personalCats)}
|
||||||
<button
|
{Array.from(groupCatMap.entries()).map(([, { name, cats }]) =>
|
||||||
key={cat.id}
|
renderCatSection(name, cats)
|
||||||
className={catItemClass(currentCategoryId === cat.id)}
|
)}
|
||||||
onClick={() => selectCategory(cat)}
|
{renderCatSection("System", systemCats)}
|
||||||
>
|
{!hasSections && catSearch && (
|
||||||
<Folder className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{cat.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{filteredCats.length === 0 && catSearch && (
|
|
||||||
<p className="text-xs text-muted px-2 py-1">No categories match</p>
|
<p className="text-xs text-muted px-2 py-1">No categories match</p>
|
||||||
)}
|
)}
|
||||||
{categories.length === 0 && !catSearch && (
|
{categories.length === 0 && !catSearch && (
|
||||||
@@ -154,18 +224,13 @@ export default function SourcePanel() {
|
|||||||
{/* Add new category */}
|
{/* Add new category */}
|
||||||
<div className="pt-2 border-t border-border mt-2">
|
<div className="pt-2 border-t border-border mt-2">
|
||||||
{addingCat ? (
|
{addingCat ? (
|
||||||
<form
|
<form onSubmit={handleAddSubmit} className="flex flex-col gap-1">
|
||||||
onSubmit={(e) => {
|
<div className="flex items-center gap-1">
|
||||||
e.preventDefault();
|
|
||||||
if (newCatName.trim()) createMut.mutate(newCatName.trim());
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
ref={addInputRef}
|
ref={addInputRef}
|
||||||
value={newCatName}
|
value={newCatName}
|
||||||
onChange={(e) => setNewCatName(e.target.value)}
|
onChange={(e) => { setNewCatName(e.target.value); setNameError(null); }}
|
||||||
placeholder="Category name"
|
placeholder="Vendor-Invoices"
|
||||||
className="h-7 text-xs flex-1"
|
className="h-7 text-xs flex-1"
|
||||||
disabled={createMut.isPending}
|
disabled={createMut.isPending}
|
||||||
/>
|
/>
|
||||||
@@ -178,11 +243,33 @@ export default function SourcePanel() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setAddingCat(false); setNewCatName(""); }}
|
onClick={() => {
|
||||||
|
setAddingCat(false);
|
||||||
|
setNewCatName("");
|
||||||
|
setNewCatGroupId("");
|
||||||
|
setNameError(null);
|
||||||
|
}}
|
||||||
className="text-muted hover:text-foreground"
|
className="text-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{myGroups.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={newCatGroupId}
|
||||||
|
onChange={(e) => setNewCatGroupId(e.target.value)}
|
||||||
|
className="h-7 text-xs rounded border border-border bg-surface text-foreground px-1"
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
>
|
||||||
|
<option value="">Personal</option>
|
||||||
|
{myGroups.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{nameError && (
|
||||||
|
<p className="text-[10px] text-red-500 leading-tight">{nameError}</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
adminUpdateGroup,
|
adminUpdateGroup,
|
||||||
adminAddGroupMember,
|
adminAddGroupMember,
|
||||||
adminRemoveGroupMember,
|
adminRemoveGroupMember,
|
||||||
|
adminSetGroupMemberAdmin,
|
||||||
adminGetUsers,
|
adminGetUsers,
|
||||||
type GroupOut,
|
type GroupOut,
|
||||||
type GroupCreate,
|
type GroupCreate,
|
||||||
@@ -249,6 +250,14 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adminMutation = useMutation({
|
||||||
|
mutationFn: ({ uId, isAdmin }: { uId: string; isAdmin: boolean }) =>
|
||||||
|
adminSetGroupMemberAdmin(groupId, uId, isAdmin),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin-group", groupId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) return <p style={{ padding: "8px 16px" }}>Loading members…</p>;
|
if (isLoading) return <p style={{ padding: "8px 16px" }}>Loading members…</p>;
|
||||||
if (!group) return null;
|
if (!group) return null;
|
||||||
|
|
||||||
@@ -268,6 +277,7 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
|
|||||||
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Email</th>
|
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Email</th>
|
||||||
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Name</th>
|
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Name</th>
|
||||||
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Status</th>
|
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Status</th>
|
||||||
|
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Group Admin</th>
|
||||||
<th style={{ padding: "4px 0", fontSize: 13 }}></th>
|
<th style={{ padding: "4px 0", fontSize: 13 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -279,6 +289,15 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
|
|||||||
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
|
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
|
||||||
{m.is_active ? "Active" : "Inactive"}
|
{m.is_active ? "Active" : "Inactive"}
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={m.is_group_admin}
|
||||||
|
disabled={adminMutation.isPending}
|
||||||
|
onChange={() => adminMutation.mutate({ uId: m.id, isAdmin: !m.is_group_admin })}
|
||||||
|
title={m.is_group_admin ? "Remove group admin" : "Make group admin"}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td style={{ padding: "4px 0" }}>
|
<td style={{ padding: "4px 0" }}>
|
||||||
<button
|
<button
|
||||||
style={{ fontSize: 12, color: "red" }}
|
style={{ fontSize: 12, color: "red" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user