AW: theCure

This commit is contained in:
2025-08-27 19:15:08 +02:00
commit 5eab37082c
32 changed files with 1188 additions and 0 deletions

12
Makefile Normal file
View 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
View 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
View 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
View 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
View 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"])

View 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())

View 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=_

View 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)

View 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

View 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"}

View 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}

View 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
View 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"]

View 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();

View 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'
)
);

View 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)
)
);

View 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
View 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:

View 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
View 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
View 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"
}
}

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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