AW: theCure
This commit is contained in:
12
Makefile
Normal file
12
Makefile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.PHONY: dev backend frontend db-up
|
||||||
|
|
||||||
|
dev: db-up backend frontend
|
||||||
|
|
||||||
|
db-up:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
backend:
|
||||||
|
docker compose up --build backend
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
docker compose up frontend
|
||||||
11
README.md
Normal file
11
README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Codeless Platform — Starter Repo
|
||||||
|
|
||||||
|
This is a scaffold for a codeless (low-code) platform similar to BMC Remedy AR System, built with:
|
||||||
|
- Supabase (Postgres + Auth + RLS)
|
||||||
|
- FastAPI + SQLAlchemy 2.0
|
||||||
|
- Nuxt 4 + Nuxt UI
|
||||||
|
- Proxmox deployment (guide in `ops/`)
|
||||||
|
|
||||||
|
## 🧭 Build Tracker
|
||||||
|
The README includes a detailed checklist of backend, frontend, DB migrations, and ops tasks.
|
||||||
|
(See our conversation for the full tracker.)
|
||||||
11
backend/Dockerfile
Normal file
11
backend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml /app/
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn[standard] sqlalchemy asyncpg pydantic python-dotenv
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
15
backend/app/db/session.py
Normal file
15
backend/app/db/session.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/postgres")
|
||||||
|
|
||||||
|
engine: AsyncEngine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||||
|
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def get_session(jwt: Optional[str] = None) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# TODO: forward Supabase JWT → Postgres for RLS
|
||||||
|
async with AsyncSession(bind=conn) as session:
|
||||||
|
yield session
|
||||||
11
backend/app/main.py
Normal file
11
backend/app/main.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from .routes import runtime, admin
|
||||||
|
|
||||||
|
app = FastAPI(title="Codeless Platform API", version="0.1.0")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.include_router(runtime.router, prefix="/api", tags=["runtime"])
|
||||||
|
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||||
65
backend/app/models/core.py
Normal file
65
backend/app/models/core.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase, mapped_column
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Application(Base):
|
||||||
|
__tablename__ = "applications"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = mapped_column(String, unique=True, nullable=False)
|
||||||
|
description = mapped_column(Text)
|
||||||
|
owner_user_id = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class Entity(Base):
|
||||||
|
__tablename__ = "entities"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
application_id = mapped_column(UUID(as_uuid=True), ForeignKey("applications.id"), nullable=False)
|
||||||
|
name = mapped_column(String, nullable=False)
|
||||||
|
label = mapped_column(String)
|
||||||
|
description = mapped_column(Text)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class Field(Base):
|
||||||
|
__tablename__ = "fields"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
entity_id = mapped_column(UUID(as_uuid=True), ForeignKey("entities.id"), nullable=False)
|
||||||
|
name = mapped_column(String, nullable=False)
|
||||||
|
label = mapped_column(String)
|
||||||
|
field_type = mapped_column(String, nullable=False)
|
||||||
|
required = mapped_column(Boolean, default=False)
|
||||||
|
default_value = mapped_column(String)
|
||||||
|
relation_entity = mapped_column(UUID(as_uuid=True), ForeignKey("entities.id"))
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class Form(Base):
|
||||||
|
__tablename__ = "forms"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
application_id = mapped_column(UUID(as_uuid=True), ForeignKey("applications.id"), nullable=False)
|
||||||
|
entity_id = mapped_column(UUID(as_uuid=True), ForeignKey("entities.id"), nullable=False)
|
||||||
|
name = mapped_column(String, nullable=False)
|
||||||
|
label = mapped_column(String)
|
||||||
|
form_type = mapped_column(String, nullable=False)
|
||||||
|
layout_json = mapped_column(JSONB, nullable=False)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class Workflow(Base):
|
||||||
|
__tablename__ = "workflows"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
application_id = mapped_column(UUID(as_uuid=True), ForeignKey("applications.id"), nullable=False)
|
||||||
|
entity_id = mapped_column(UUID(as_uuid=True), ForeignKey("entities.id"), nullable=False)
|
||||||
|
name = mapped_column(String, nullable=False)
|
||||||
|
description = mapped_column(Text)
|
||||||
|
trigger_event = mapped_column(String, nullable=False)
|
||||||
|
condition_json = mapped_column(JSONB)
|
||||||
|
action_json = mapped_column(JSONB)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
48
backend/app/models/security.py
Normal file
48
backend/app/models/security.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from sqlalchemy.orm import mapped_column
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import uuid
|
||||||
|
from .core import Base
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "roles"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = mapped_column(String, unique=True, nullable=False)
|
||||||
|
description = mapped_column(Text)
|
||||||
|
parent_role_id = mapped_column(UUID(as_uuid=True), ForeignKey("roles.id"))
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
email = mapped_column(String, unique=True)
|
||||||
|
display_name = mapped_column(String)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class UserRole(Base):
|
||||||
|
__tablename__ = "user_roles"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
role_id = mapped_column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
class EntityPermission(Base):
|
||||||
|
__tablename__ = "entity_permissions"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
role_id = mapped_column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False)
|
||||||
|
entity_id = mapped_column(UUID(as_uuid=True), ForeignKey("entities.id"), nullable=False)
|
||||||
|
can_create = mapped_column(Boolean, default=False)
|
||||||
|
can_read = mapped_column(Boolean, default=False)
|
||||||
|
can_update = mapped_column(Boolean, default=False)
|
||||||
|
can_delete = mapped_column(Boolean, default=False)
|
||||||
|
row_filter_json = mapped_column(JSONB)
|
||||||
|
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
class FieldPermission(Base):
|
||||||
|
__tablename__ = "field_permissions"
|
||||||
|
id = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
role_id = mapped_column(UUID(as_uuid=_
|
||||||
27
backend/app/models/views.py
Normal file
27
backend/app/models/views.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy.orm import mapped_column
|
||||||
|
from sqlalchemy import String, Boolean
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from .core import Base
|
||||||
|
|
||||||
|
# Read-only ORM mappings to Postgres views
|
||||||
|
|
||||||
|
class CurrentUserEntityPermission(Base):
|
||||||
|
__tablename__ = "current_user_entity_permissions"
|
||||||
|
user_id = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
entity_id = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
entity_name = mapped_column(String)
|
||||||
|
can_create = mapped_column(Boolean)
|
||||||
|
can_read = mapped_column(Boolean)
|
||||||
|
can_update = mapped_column(Boolean)
|
||||||
|
can_delete = mapped_column(Boolean)
|
||||||
|
row_filters = mapped_column(JSONB)
|
||||||
|
|
||||||
|
class CurrentUserFieldPermission(Base):
|
||||||
|
__tablename__ = "current_user_field_permissions"
|
||||||
|
user_id = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
field_id = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
entity_id = mapped_column(UUID(as_uuid=True))
|
||||||
|
field_name = mapped_column(String)
|
||||||
|
entity_name = mapped_column(String)
|
||||||
|
can_read = mapped_column(Boolean)
|
||||||
|
can_update = mapped_column(Boolean)
|
||||||
12
backend/app/routes/admin.py
Normal file
12
backend/app/routes/admin.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Stub endpoints for Admin (4GL) APIs — expand later.
|
||||||
|
@router.get("/apps")
|
||||||
|
async def list_apps():
|
||||||
|
return []
|
||||||
|
|
||||||
|
@router.post("/apps")
|
||||||
|
async def create_app(payload: dict):
|
||||||
|
return payload
|
||||||
23
backend/app/routes/runtime.py
Normal file
23
backend/app/routes/runtime.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from ..db.session import get_session
|
||||||
|
from ..services import runtime_crud
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/{entity}/records")
|
||||||
|
async def list_records(entity: str, authorization: str | None = Header(default=None), session: AsyncSession = Depends(get_session)):
|
||||||
|
return await runtime_crud.list_records(session, entity)
|
||||||
|
|
||||||
|
@router.post("/{entity}/records")
|
||||||
|
async def create_record(entity: str, payload: dict, authorization: str | None = Header(default=None), session: AsyncSession = Depends(get_session)):
|
||||||
|
return await runtime_crud.create_record(session, entity, payload)
|
||||||
|
|
||||||
|
@router.patch("/{entity}/records/{record_id}")
|
||||||
|
async def update_record(entity: str, record_id: str, payload: dict, authorization: str | None = Header(default=None), session: AsyncSession = Depends(get_session)):
|
||||||
|
return await runtime_crud.update_record(session, entity, record_id, payload)
|
||||||
|
|
||||||
|
@router.delete("/{entity}/records/{record_id}")
|
||||||
|
async def delete_record(entity: str, record_id: str, authorization: str | None = Header(default=None), session: AsyncSession = Depends(get_session)):
|
||||||
|
await runtime_crud.delete_record(session, entity, record_id)
|
||||||
|
return {"status": "deleted"}
|
||||||
30
backend/app/services/permissions.py
Normal file
30
backend/app/services/permissions.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Dict, Set
|
||||||
|
from ..models.views import CurrentUserEntityPermission, CurrentUserFieldPermission
|
||||||
|
|
||||||
|
async def fetch_entity_permissions(session: AsyncSession) -> Dict[str, dict]:
|
||||||
|
result = await session.execute(select(CurrentUserEntityPermission))
|
||||||
|
perms = {}
|
||||||
|
for row in result.scalars():
|
||||||
|
perms[row.entity_name] = {
|
||||||
|
"can_create": row.can_create,
|
||||||
|
"can_read": row.can_read,
|
||||||
|
"can_update": row.can_update,
|
||||||
|
"can_delete": row.can_delete,
|
||||||
|
"row_filters": row.row_filters,
|
||||||
|
}
|
||||||
|
return perms
|
||||||
|
|
||||||
|
async def fetch_field_permissions(session: AsyncSession) -> Dict[str, Set[str]]:
|
||||||
|
result = await session.execute(select(CurrentUserFieldPermission))
|
||||||
|
readable: Dict[str, Set[str]] = {}
|
||||||
|
updatable: Dict[str, Set[str]] = {}
|
||||||
|
for row in result.scalars():
|
||||||
|
readable.setdefault(row.entity_name, set())
|
||||||
|
updatable.setdefault(row.entity_name, set())
|
||||||
|
if row.can_read:
|
||||||
|
readable[row.entity_name].add(row.field_name)
|
||||||
|
if row.can_update:
|
||||||
|
updatable[row.entity_name].add(row.field_name)
|
||||||
|
return {"readable": readable, "updatable": updatable}
|
||||||
54
backend/app/services/runtime_crud.py
Normal file
54
backend/app/services/runtime_crud.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from typing import Any, Dict, List
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# Minimal demo CRUD for tickets entity.
|
||||||
|
async def list_records(session: AsyncSession, entity: str, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||||
|
if entity != 'tickets':
|
||||||
|
return []
|
||||||
|
stmt = text("""
|
||||||
|
select id, title, description, status, created_by, created_at
|
||||||
|
from public.tickets
|
||||||
|
order by created_at desc
|
||||||
|
limit :limit offset :offset
|
||||||
|
""")
|
||||||
|
result = await session.execute(stmt, dict(limit=limit, offset=offset))
|
||||||
|
return [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
async def create_record(session: AsyncSession, entity: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if entity != 'tickets':
|
||||||
|
return payload
|
||||||
|
stmt = text("""
|
||||||
|
insert into public.tickets (title, description, status, created_by)
|
||||||
|
values (:title, :description, coalesce(:status, 'open'), coalesce(:created_by, gen_random_uuid()))
|
||||||
|
returning id, title, description, status, created_by, created_at
|
||||||
|
""")
|
||||||
|
result = await session.execute(stmt, payload)
|
||||||
|
row = result.first()
|
||||||
|
await session.commit()
|
||||||
|
return dict(row._mapping) if row else {}
|
||||||
|
|
||||||
|
async def update_record(session: AsyncSession, entity: str, record_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if entity != 'tickets':
|
||||||
|
return payload
|
||||||
|
stmt = text("""
|
||||||
|
update public.tickets
|
||||||
|
set title = coalesce(:title, title),
|
||||||
|
description = coalesce(:description, description),
|
||||||
|
status = coalesce(:status, status)
|
||||||
|
where id = :id
|
||||||
|
returning id, title, description, status, created_by, created_at
|
||||||
|
""")
|
||||||
|
params = {**payload, "id": record_id}
|
||||||
|
result = await session.execute(stmt, params)
|
||||||
|
row = result.first()
|
||||||
|
await session.commit()
|
||||||
|
return dict(row._mapping) if row else {}
|
||||||
|
|
||||||
|
async def delete_record(session: AsyncSession, entity: str, record_id: str) -> None:
|
||||||
|
if entity != 'tickets':
|
||||||
|
return None
|
||||||
|
stmt = text("delete from public.tickets where id = :id")
|
||||||
|
await session.execute(stmt, {"id": record_id})
|
||||||
|
await session.commit()
|
||||||
|
return None
|
||||||
21
backend/pyporject.toml
Normal file
21
backend/pyporject.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "codeless-platform-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI + SQLAlchemy backend for the codeless platform"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.30",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"asyncpg>=0.29",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"python-dotenv>=1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["app"]
|
||||||
|
exclude = ["tests"]
|
||||||
163
db/migrations/0001_core_schema.sql
Normal file
163
db/migrations/0001_core_schema.sql
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
-- 0001_core_schema.sql
|
||||||
|
-- Portable Postgres core schema (no Supabase-specific RLS).
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto; -- for gen_random_uuid()
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- APPLICATIONS
|
||||||
|
-- =======================================
|
||||||
|
create table if not exists public.applications (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null unique,
|
||||||
|
description text,
|
||||||
|
owner_user_id uuid,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- ENTITIES
|
||||||
|
-- =======================================
|
||||||
|
create table if not exists public.entities (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
application_id uuid not null references public.applications(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
label text,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now(),
|
||||||
|
unique (application_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- FIELDS
|
||||||
|
-- =======================================
|
||||||
|
create table if not exists public.fields (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
entity_id uuid not null references public.entities(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
label text,
|
||||||
|
field_type text not null, -- string, number, boolean, date, relation, etc.
|
||||||
|
required boolean default false,
|
||||||
|
default_value text,
|
||||||
|
relation_entity uuid references public.entities(id),
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now(),
|
||||||
|
unique (entity_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- FORMS
|
||||||
|
-- =======================================
|
||||||
|
create table if not exists public.forms (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
application_id uuid not null references public.applications(id) on delete cascade,
|
||||||
|
entity_id uuid not null references public.entities(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
label text,
|
||||||
|
form_type text not null, -- create, edit, view, list
|
||||||
|
layout_json jsonb not null, -- UI layout structure
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now(),
|
||||||
|
unique (entity_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- WORKFLOWS
|
||||||
|
-- =======================================
|
||||||
|
create table if not exists public.workflows (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
application_id uuid not null references public.applications(id) on delete cascade,
|
||||||
|
entity_id uuid not null references public.entities(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
trigger_event text not null, -- on_create, on_update, on_delete
|
||||||
|
condition_json jsonb,
|
||||||
|
action_json jsonb,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now(),
|
||||||
|
unique (entity_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- SECURITY: ROLES / USERS / PERMISSIONS
|
||||||
|
-- =======================================
|
||||||
|
create table if not exists public.roles (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null unique,
|
||||||
|
description text,
|
||||||
|
parent_role_id uuid references public.roles(id) on delete set null,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text unique,
|
||||||
|
display_name text,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.user_roles (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.users(id) on delete cascade,
|
||||||
|
role_id uuid not null references public.roles(id) on delete cascade,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
unique (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.entity_permissions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
role_id uuid not null references public.roles(id) on delete cascade,
|
||||||
|
entity_id uuid not null references public.entities(id) on delete cascade,
|
||||||
|
can_create boolean default false,
|
||||||
|
can_read boolean default false,
|
||||||
|
can_update boolean default false,
|
||||||
|
can_delete boolean default false,
|
||||||
|
row_filter_json jsonb, -- e.g. {"field":"owner_id","op":"=","value":"auth.uid()"}
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now(),
|
||||||
|
unique (role_id, entity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.field_permissions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
role_id uuid not null references public.roles(id) on delete cascade,
|
||||||
|
field_id uuid not null references public.fields(id) on delete cascade,
|
||||||
|
can_read boolean default false,
|
||||||
|
can_update boolean default false,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now(),
|
||||||
|
unique (role_id, field_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- TIMESTAMP TRIGGER
|
||||||
|
-- =======================================
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
create trigger trg_updated_at_applications before update on public.applications
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_entities before update on public.entities
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_fields before update on public.fields
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_forms before update on public.forms
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_workflows before update on public.workflows
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_roles before update on public.roles
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_users before update on public.users
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_entity_permissions before update on public.entity_permissions
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
|
create trigger trg_updated_at_field_permissions before update on public.field_permissions
|
||||||
|
for each row execute procedure public.set_updated_at();
|
||||||
103
db/migrations/0002_supabase_objects.sql
Normal file
103
db/migrations/0002_supabase_objects.sql
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
-- 0002_supabase_objects.sql
|
||||||
|
-- Supabase wiring: auth sync, RLS-enabled permission views and stubs.
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
-- Link auth.users to public.users
|
||||||
|
alter table public.users
|
||||||
|
add column if not exists supabase_uid uuid unique;
|
||||||
|
|
||||||
|
create or replace function public.handle_new_supabase_user()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
insert into public.users (id, supabase_uid, email, display_name)
|
||||||
|
values (gen_random_uuid(), new.id, new.email, coalesce(new.raw_user_meta_data->>'full_name', new.email))
|
||||||
|
on conflict (supabase_uid) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute function public.handle_new_supabase_user();
|
||||||
|
|
||||||
|
-- Expanded roles for CURRENT user (via auth.uid())
|
||||||
|
create or replace view public.current_user_roles as
|
||||||
|
with recursive role_hierarchy as (
|
||||||
|
select ur.user_id, r.id as role_id, r.parent_role_id
|
||||||
|
from public.user_roles ur
|
||||||
|
join public.users u on ur.user_id = u.id
|
||||||
|
join public.roles r on ur.role_id = r.id
|
||||||
|
where u.supabase_uid = auth.uid()
|
||||||
|
union
|
||||||
|
select rh.user_id, pr.id as role_id, pr.parent_role_id
|
||||||
|
from role_hierarchy rh
|
||||||
|
join public.roles pr on rh.parent_role_id = pr.id
|
||||||
|
)
|
||||||
|
select distinct user_id, role_id from role_hierarchy;
|
||||||
|
|
||||||
|
-- Effective entity permissions for CURRENT user
|
||||||
|
create or replace view public.current_user_entity_permissions as
|
||||||
|
select
|
||||||
|
ure.user_id,
|
||||||
|
e.id as entity_id,
|
||||||
|
e.name as entity_name,
|
||||||
|
bool_or(ep.can_create) as can_create,
|
||||||
|
bool_or(ep.can_read) as can_read,
|
||||||
|
bool_or(ep.can_update) as can_update,
|
||||||
|
bool_or(ep.can_delete) as can_delete,
|
||||||
|
array_remove(array_agg(ep.row_filter_json), null)::jsonb[] as row_filters
|
||||||
|
from public.current_user_roles ure
|
||||||
|
join public.entity_permissions ep on ure.role_id = ep.role_id
|
||||||
|
join public.entities e on ep.entity_id = e.id
|
||||||
|
group by ure.user_id, e.id, e.name;
|
||||||
|
|
||||||
|
-- Effective field permissions for CURRENT user
|
||||||
|
create or replace view public.current_user_field_permissions as
|
||||||
|
select
|
||||||
|
ure.user_id,
|
||||||
|
f.id as field_id,
|
||||||
|
f.name as field_name,
|
||||||
|
e.id as entity_id,
|
||||||
|
e.name as entity_name,
|
||||||
|
bool_or(fp.can_read) as can_read,
|
||||||
|
bool_or(fp.can_update) as can_update
|
||||||
|
from public.current_user_roles ure
|
||||||
|
join public.field_permissions fp on ure.role_id = fp.role_id
|
||||||
|
join public.fields f on fp.field_id = f.id
|
||||||
|
join public.entities e on f.entity_id = e.id
|
||||||
|
group by ure.user_id, f.id, f.name, e.id, e.name;
|
||||||
|
|
||||||
|
-- Enable RLS on core security tables
|
||||||
|
alter table public.roles enable row level security;
|
||||||
|
alter table public.user_roles enable row level security;
|
||||||
|
alter table public.entity_permissions enable row level security;
|
||||||
|
alter table public.field_permissions enable row level security;
|
||||||
|
|
||||||
|
-- Basic policies
|
||||||
|
drop policy if exists "users see their user_roles" on public.user_roles;
|
||||||
|
create policy "users see their user_roles"
|
||||||
|
on public.user_roles
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.users u
|
||||||
|
where u.id = public.user_roles.user_id
|
||||||
|
and u.supabase_uid = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Example admin policy (adjust as needed)
|
||||||
|
drop policy if exists "admins manage roles" on public.roles;
|
||||||
|
create policy "admins manage roles"
|
||||||
|
on public.roles
|
||||||
|
for all
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.current_user_roles cur
|
||||||
|
join public.roles r on cur.role_id = r.id
|
||||||
|
where r.name = 'Admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
133
db/migrations/0003_row_filters.sql
Normal file
133
db/migrations/0003_row_filters.sql
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
-- 0003_row_filters.sql
|
||||||
|
-- Row filter evaluator + demo policies.
|
||||||
|
|
||||||
|
-- Evaluate array of JSONB filter objects against a row (as jsonb).
|
||||||
|
-- Supported:
|
||||||
|
-- ops: =, !=, in, not_in, >, <, between
|
||||||
|
-- logic: and/or with structure: {"and":[...]} or {"or":[...]}
|
||||||
|
|
||||||
|
create or replace function public.check_row_filter(row_data jsonb, filters jsonb[])
|
||||||
|
returns boolean as $$
|
||||||
|
declare
|
||||||
|
f jsonb;
|
||||||
|
ok boolean := true;
|
||||||
|
val text;
|
||||||
|
lhs text;
|
||||||
|
op text;
|
||||||
|
begin
|
||||||
|
if filters is null then
|
||||||
|
return true;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
foreach f in array filters loop
|
||||||
|
-- Composite logic
|
||||||
|
if f ? 'and' then
|
||||||
|
if not public.check_row_filter(row_data, array(select jsonb_array_elements(f->'and'))) then
|
||||||
|
return false;
|
||||||
|
end if;
|
||||||
|
continue;
|
||||||
|
elsif f ? 'or' then
|
||||||
|
-- at least one must pass
|
||||||
|
ok := false;
|
||||||
|
for f in select jsonb_array_elements(f->'or') loop
|
||||||
|
if public.check_row_filter(row_data, array[f]) then
|
||||||
|
ok := true; exit;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
if not ok then return false; end if;
|
||||||
|
continue;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
lhs := row_data->>(f->>'field');
|
||||||
|
op := f->>'op';
|
||||||
|
|
||||||
|
if f->>'value' = 'auth.uid()' then
|
||||||
|
val := auth.uid()::text;
|
||||||
|
else
|
||||||
|
val := f->>'value';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if op = '=' then
|
||||||
|
if lhs is null or lhs <> val then return false; end if;
|
||||||
|
elsif op = '!=' then
|
||||||
|
if lhs = val then return false; end if;
|
||||||
|
elsif op = 'in' then
|
||||||
|
if not (lhs = any (select jsonb_array_elements_text(f->'values'))) then return false; end if;
|
||||||
|
elsif op = 'not_in' then
|
||||||
|
if (lhs = any (select jsonb_array_elements_text(f->'values'))) then return false; end if;
|
||||||
|
elsif op = '>' then
|
||||||
|
if lhs is null or lhs <= val then return false; end if;
|
||||||
|
elsif op = '<' then
|
||||||
|
if lhs is null or lhs >= val then return false; end if;
|
||||||
|
elsif op = 'between' then
|
||||||
|
if lhs is null or not (lhs >= (f->>'min') and lhs <= (f->>'max')) then return false; end if;
|
||||||
|
else
|
||||||
|
-- unknown op -> deny
|
||||||
|
return false;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql stable;
|
||||||
|
|
||||||
|
-- Demo runtime table: tickets
|
||||||
|
create table if not exists public.tickets (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
title text not null,
|
||||||
|
description text,
|
||||||
|
status text default 'open',
|
||||||
|
created_by uuid not null, -- should match users.supabase_uid for auth.uid() checks
|
||||||
|
created_at timestamptz default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.tickets enable row level security;
|
||||||
|
|
||||||
|
-- Example policies using current_user_entity_permissions + check_row_filter
|
||||||
|
-- For demo we key by entity_name = 'tickets' (in your production, key by entity_id).
|
||||||
|
drop policy if exists "tickets_select_policy" on public.tickets;
|
||||||
|
create policy "tickets_select_policy" on public.tickets
|
||||||
|
for select using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.current_user_entity_permissions cuep
|
||||||
|
where cuep.entity_name = 'tickets'
|
||||||
|
and cuep.can_read = true
|
||||||
|
and public.check_row_filter(to_jsonb(public.tickets), cuep.row_filters)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "tickets_insert_policy" on public.tickets;
|
||||||
|
create policy "tickets_insert_policy" on public.tickets
|
||||||
|
for insert with check (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.current_user_entity_permissions cuep
|
||||||
|
where cuep.entity_name = 'tickets'
|
||||||
|
and cuep.can_create = true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "tickets_update_policy" on public.tickets;
|
||||||
|
create policy "tickets_update_policy" on public.tickets
|
||||||
|
for update using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.current_user_entity_permissions cuep
|
||||||
|
where cuep.entity_name = 'tickets'
|
||||||
|
and cuep.can_update = true
|
||||||
|
and public.check_row_filter(to_jsonb(public.tickets), cuep.row_filters)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "tickets_delete_policy" on public.tickets;
|
||||||
|
create policy "tickets_delete_policy" on public.tickets
|
||||||
|
for delete using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.current_user_entity_permissions cuep
|
||||||
|
where cuep.entity_name = 'tickets'
|
||||||
|
and cuep.can_delete = true
|
||||||
|
and public.check_row_filter(to_jsonb(public.tickets), cuep.row_filters)
|
||||||
|
)
|
||||||
|
);
|
||||||
82
db/migrations/0004_seed_demo.sql
Normal file
82
db/migrations/0004_seed_demo.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- 0004_seed_demo.sql
|
||||||
|
-- Seed demo roles, user, entity, fields, form, workflow.
|
||||||
|
|
||||||
|
-- Roles
|
||||||
|
insert into public.roles (id, name, description)
|
||||||
|
values (gen_random_uuid(), 'Admin', 'Full access admin role')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.roles (id, name, description)
|
||||||
|
values (gen_random_uuid(), 'Manager', 'Manager role')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.roles (id, name, description)
|
||||||
|
values (gen_random_uuid(), 'Agent', 'Agent role')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Demo user (dummy UUID, replace with actual Supabase user id later if desired)
|
||||||
|
insert into public.users (id, email, display_name)
|
||||||
|
values (gen_random_uuid(), 'demo@example.com', 'Demo User')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Link demo user to Agent role
|
||||||
|
insert into public.user_roles (user_id, role_id)
|
||||||
|
select u.id, r.id from public.users u, public.roles r
|
||||||
|
where u.email = 'demo@example.com' and r.name = 'Agent'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Demo application
|
||||||
|
insert into public.applications (id, name, description)
|
||||||
|
values (gen_random_uuid(), 'Helpdesk', 'Demo helpdesk app with tickets')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Demo entity: tickets
|
||||||
|
insert into public.entities (id, application_id, name, label, description)
|
||||||
|
select gen_random_uuid(), a.id, 'tickets', 'Tickets', 'Support tickets'
|
||||||
|
from public.applications a
|
||||||
|
where a.name = 'Helpdesk'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Demo fields for tickets
|
||||||
|
insert into public.fields (id, entity_id, name, label, field_type, required)
|
||||||
|
select gen_random_uuid(), e.id, 'title', 'Title', 'string', true
|
||||||
|
from public.entities e where e.name = 'tickets'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.fields (id, entity_id, name, label, field_type)
|
||||||
|
select gen_random_uuid(), e.id, 'description', 'Description', 'textarea'
|
||||||
|
from public.entities e where e.name = 'tickets'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.fields (id, entity_id, name, label, field_type)
|
||||||
|
select gen_random_uuid(), e.id, 'status', 'Status', 'string'
|
||||||
|
from public.entities e where e.name = 'tickets'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into public.fields (id, entity_id, name, label, field_type)
|
||||||
|
select gen_random_uuid(), e.id, 'created_by', 'Created By', 'uuid'
|
||||||
|
from public.entities e where e.name = 'tickets'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Demo form layout
|
||||||
|
insert into public.forms (id, application_id, entity_id, name, label, form_type, layout_json)
|
||||||
|
select gen_random_uuid(), a.id, e.id, 'ticket_form', 'Ticket Form', 'create',
|
||||||
|
'{
|
||||||
|
"sections":[
|
||||||
|
{"title":"Ticket Info","fields":["title","description","status"]}
|
||||||
|
]
|
||||||
|
}'::jsonb
|
||||||
|
from public.applications a
|
||||||
|
join public.entities e on e.application_id = a.id
|
||||||
|
where a.name = 'Helpdesk' and e.name = 'tickets'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- Demo workflow (on_create -> set status open)
|
||||||
|
insert into public.workflows (id, application_id, entity_id, name, description, trigger_event, condition_json, action_json)
|
||||||
|
select gen_random_uuid(), a.id, e.id, 'ticket_on_create', 'Set ticket status to open on create', 'on_create',
|
||||||
|
null,
|
||||||
|
'[{"action":"set_field","field":"status","value":"open"}]'::jsonb
|
||||||
|
from public.applications a
|
||||||
|
join public.entities e on e.application_id = a.id
|
||||||
|
where a.name = 'Helpdesk' and e.name = 'tickets'
|
||||||
|
on conflict do nothing;
|
||||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- dbdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/postgres
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
working_dir: /app
|
||||||
|
image: node:20
|
||||||
|
command: sh -c "npm i && npx nuxt dev -p 3000 -H 0.0.0.0"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dbdata:
|
||||||
102
frontend/components/DynamicForm.vue
Normal file
102
frontend/components/DynamicForm.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
type FieldDef = {
|
||||||
|
name: string
|
||||||
|
label?: string
|
||||||
|
type?: string
|
||||||
|
required?: boolean
|
||||||
|
options?: Array<{ label: string; value: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayoutSection = {
|
||||||
|
title?: string
|
||||||
|
fields: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormLayout = {
|
||||||
|
sections: LayoutSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
layout: FormLayout
|
||||||
|
fieldsMeta?: Record<string, FieldDef>
|
||||||
|
modelValue?: Record<string, any>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: Record<string, any>): void
|
||||||
|
(e: 'submit', v: Record<string, any>): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const state = ref<Record<string, any>>({ ...(props.modelValue || {}) })
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => {
|
||||||
|
if (v) state.value = { ...v }
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateField(name: string, value: any) {
|
||||||
|
state.value[name] = value
|
||||||
|
emit('update:modelValue', state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
emit('submit', state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedLabel = (name: string) => props.fieldsMeta?.[name]?.label || name
|
||||||
|
const resolvedType = (name: string) => props.fieldsMeta?.[name]?.type || 'text'
|
||||||
|
const isRequired = (name: string) => !!props.fieldsMeta?.[name]?.required
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="onSubmit">
|
||||||
|
<div v-for="(section, i) in layout.sections" :key="i" class="space-y-4">
|
||||||
|
<h3 v-if="section.title" class="text-lg font-semibold">{{ section.title }}</h3>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div v-for="field in section.fields" :key="field" class="space-y-1">
|
||||||
|
<label class="text-sm font-medium">{{ resolvedLabel(field) }}</label>
|
||||||
|
|
||||||
|
<UInput
|
||||||
|
v-if="['text','string','number'].includes(resolvedType(field))"
|
||||||
|
:type="resolvedType(field) === 'number' ? 'number' : 'text'"
|
||||||
|
v-model="state[field]"
|
||||||
|
:required="isRequired(field)"
|
||||||
|
@update:model-value="v => updateField(field, v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UTextarea
|
||||||
|
v-else-if="['text','textarea'].includes(resolvedType(field))"
|
||||||
|
v-model="state[field]"
|
||||||
|
:required="isRequired(field)"
|
||||||
|
@update:model-value="v => updateField(field, v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USelect
|
||||||
|
v-else-if="resolvedType(field) === 'select'"
|
||||||
|
:options="props.fieldsMeta?.[field]?.options || []"
|
||||||
|
v-model="state[field]"
|
||||||
|
@update:model-value="v => updateField(field, v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UCheckbox
|
||||||
|
v-else-if="resolvedType(field) === 'checkbox'"
|
||||||
|
v-model="state[field]"
|
||||||
|
@update:model-value="v => updateField(field, v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UInput
|
||||||
|
v-else
|
||||||
|
type="text"
|
||||||
|
v-model="state[field]"
|
||||||
|
@update:model-value="v => updateField(field, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<UButton type="submit">Submit</UButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
6
frontend/nuxt.config.ts
Normal file
6
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: ['@nuxt/ui'],
|
||||||
|
compatibilityDate: '2024-10-01',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
typescript: { strict: true }
|
||||||
|
})
|
||||||
14
frontend/package.json
Normal file
14
frontend/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "codeless-platform-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"start": "nuxt start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nuxt": "^4.0.0-rc.0",
|
||||||
|
"@nuxt/ui": "^2.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/pages/admin/apps.vue
Normal file
8
frontend/pages/admin/apps.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Admin / Apps</h2></template>
|
||||||
|
<p>Scaffold placeholder. Add CRUD UI for applications.</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/pages/admin/entities/[id]/fields.vue
Normal file
8
frontend/pages/admin/entities/[id]/fields.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Admin / Entity Fields</h2></template>
|
||||||
|
<p>Scaffold placeholder. Add fields to the selected entity.</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/pages/admin/entities/index.vue
Normal file
8
frontend/pages/admin/entities/index.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Admin / Entities</h2></template>
|
||||||
|
<p>Scaffold placeholder. List and manage entities here.</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/pages/admin/forms.vue
Normal file
8
frontend/pages/admin/forms.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Admin / Forms</h2></template>
|
||||||
|
<p>Scaffold placeholder. Manage form layouts (layout_json) here.</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/pages/admin/workflows.vue
Normal file
8
frontend/pages/admin/workflows.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Admin / Workflows</h2></template>
|
||||||
|
<p>Scaffold placeholder. Manage workflows (trigger_event, condition_json, action_json).</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
frontend/pages/index.vue
Normal file
16
frontend/pages/index.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<h1 class="text-2xl font-semibold">Codeless Platform</h1>
|
||||||
|
<p class="mt-2">Nuxt 4 + Nuxt UI starter running.</p>
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<UButton to="/admin/apps" color="primary" variant="solid">Admin Studio</UButton>
|
||||||
|
<UButton to="/runtime/tickets/list" variant="soft">Tickets (Demo)</UButton>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-xs text-gray-500">
|
||||||
|
Tip: set your backend base URL in localStorage:
|
||||||
|
<code>localStorage.setItem('API_BASE','http://localhost:8000')</code>
|
||||||
|
</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/pages/runtime/[entity]/[id]/edit.vue
Normal file
8
frontend/pages/runtime/[entity]/[id]/edit.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Edit Record</h2></template>
|
||||||
|
<p>Placeholder. Implement edit form honoring field update permissions.</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
frontend/pages/runtime/[entity]/[id]/view.vue
Normal file
8
frontend/pages/runtime/[entity]/[id]/view.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<UCard>
|
||||||
|
<template #header><h2 class="text-xl font-semibold">Record View</h2></template>
|
||||||
|
<p>Placeholder. Implement detail view honoring field read permissions.</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
53
frontend/pages/runtime/tickets/list.vue
Normal file
53
frontend/pages/runtime/tickets/list.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const entity = 'tickets'
|
||||||
|
const baseURL = process.client ? (localStorage.getItem('API_BASE') || 'http://localhost:8000') : ''
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseURL}/api/${entity}/records`)
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
items.value = await res.json()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold">Tickets</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton to="/runtime/tickets/new">New</UButton>
|
||||||
|
<UButton to="/">Home</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading" class="py-8"><ULoading /></div>
|
||||||
|
|
||||||
|
<ul v-else class="space-y-2">
|
||||||
|
<li v-for="it in items" :key="it.id" class="p-3 border rounded">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ it.title }}</strong>
|
||||||
|
<div class="text-sm text-gray-600">{{ it.status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ new Date(it.created_at).toLocaleString() }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<p v-if="error" class="text-red-600 text-sm">{{ error }}</p>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
55
frontend/pages/runtime/tickets/new.vue
Normal file
55
frontend/pages/runtime/tickets/new.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const entity = 'tickets'
|
||||||
|
const baseURL = process.client ? (localStorage.getItem('API_BASE') || 'http://localhost:8000') : ''
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const formLayout = ref<{ sections: Array<{ title?: string; fields: string[] }> }>({
|
||||||
|
sections: [{ title: 'Ticket Info', fields: ['title', 'description', 'status'] }]
|
||||||
|
})
|
||||||
|
const fieldsMeta = ref<Record<string, any>>({
|
||||||
|
title: { label: 'Title', type: 'string', required: true },
|
||||||
|
description: { label: 'Description', type: 'textarea' },
|
||||||
|
status: { label: 'Status', type: 'select', options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }] }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(payload: Record<string, any>) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await fetch(`${baseURL}/api/${entity}/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
navigateTo(`/runtime/${entity}/list`)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold">New Ticket</h2>
|
||||||
|
<UButton variant="ghost" to="/runtime/tickets/list">Back to list</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading" class="py-8"><ULoading /></div>
|
||||||
|
<div v-else>
|
||||||
|
<DynamicForm :layout="formLayout" :fields-meta="fieldsMeta" @submit="handleSubmit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<p v-if="error" class="text-red-600 text-sm">{{ error }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Set localStorage API_BASE to your backend URL if not localhost:8000</p>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
30
ops/DEPLOY_PROXMOX.md
Normal file
30
ops/DEPLOY_PROXMOX.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Proxmox Deployment Guide (Starter)
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
- VM/CT 1: Postgres (or Supabase managed)
|
||||||
|
- VM/CT 2: Backend (FastAPI)
|
||||||
|
- VM/CT 3: Frontend (Nuxt SSR or static behind Nginx)
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
Backend:
|
||||||
|
- `DATABASE_URL=postgresql+asyncpg://USER:PASS@DB_HOST:5432/DBNAME`
|
||||||
|
- For Supabase: use the provided connection string (SSL), and forward JWT to Postgres for RLS.
|
||||||
|
|
||||||
|
## Reverse Proxy (TLS)
|
||||||
|
Use Caddy or Nginx:
|
||||||
|
- `/api` -> backend:8000
|
||||||
|
- `/` -> frontend:3000 (SSR) or static files
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
Apply in order:
|
||||||
|
1. `db/migrations/0001_core_schema.sql`
|
||||||
|
2. `db/migrations/0002_supabase_objects.sql`
|
||||||
|
3. `db/migrations/0003_row_filters.sql`
|
||||||
|
4. `db/migrations/0004_seed_demo.sql`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f db/migrations/0001_core_schema.sql
|
||||||
|
psql "$DATABASE_URL" -f db/migrations/0002_supabase_objects.sql
|
||||||
|
psql "$DATABASE_URL" -f db/migrations/0003_row_filters.sql
|
||||||
|
psql "$DATABASE_URL" -f db/migrations/0004_seed_demo.sql
|
||||||
Reference in New Issue
Block a user