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