commit 5eab37082c725a150eddf42413bea6e2c1ab53fb Author: Anders Wilhelm Date: Wed Aug 27 19:15:08 2025 +0200 AW: theCure diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4038d09 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..68ee602 --- /dev/null +++ b/README.md @@ -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.) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3a2c432 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..d3c0a77 --- /dev/null +++ b/backend/app/db/session.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..547b698 --- /dev/null +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models/core.py b/backend/app/models/core.py new file mode 100644 index 0000000..75f0743 --- /dev/null +++ b/backend/app/models/core.py @@ -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()) diff --git a/backend/app/models/security.py b/backend/app/models/security.py new file mode 100644 index 0000000..79fee02 --- /dev/null +++ b/backend/app/models/security.py @@ -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=_ diff --git a/backend/app/models/views.py b/backend/app/models/views.py new file mode 100644 index 0000000..c8995f1 --- /dev/null +++ b/backend/app/models/views.py @@ -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) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..336cf77 --- /dev/null +++ b/backend/app/routes/admin.py @@ -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 diff --git a/backend/app/routes/runtime.py b/backend/app/routes/runtime.py new file mode 100644 index 0000000..2bcc583 --- /dev/null +++ b/backend/app/routes/runtime.py @@ -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"} diff --git a/backend/app/services/permissions.py b/backend/app/services/permissions.py new file mode 100644 index 0000000..c464072 --- /dev/null +++ b/backend/app/services/permissions.py @@ -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} diff --git a/backend/app/services/runtime_crud.py b/backend/app/services/runtime_crud.py new file mode 100644 index 0000000..22bbd94 --- /dev/null +++ b/backend/app/services/runtime_crud.py @@ -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 diff --git a/backend/pyporject.toml b/backend/pyporject.toml new file mode 100644 index 0000000..8f52049 --- /dev/null +++ b/backend/pyporject.toml @@ -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"] diff --git a/db/migrations/0001_core_schema.sql b/db/migrations/0001_core_schema.sql new file mode 100644 index 0000000..64a8771 --- /dev/null +++ b/db/migrations/0001_core_schema.sql @@ -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(); diff --git a/db/migrations/0002_supabase_objects.sql b/db/migrations/0002_supabase_objects.sql new file mode 100644 index 0000000..b2e8cf1 --- /dev/null +++ b/db/migrations/0002_supabase_objects.sql @@ -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' + ) +); diff --git a/db/migrations/0003_row_filters.sql b/db/migrations/0003_row_filters.sql new file mode 100644 index 0000000..138847f --- /dev/null +++ b/db/migrations/0003_row_filters.sql @@ -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) + ) +); diff --git a/db/migrations/0004_seed_demo.sql b/db/migrations/0004_seed_demo.sql new file mode 100644 index 0000000..825c062 --- /dev/null +++ b/db/migrations/0004_seed_demo.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da415ef --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/components/DynamicForm.vue b/frontend/components/DynamicForm.vue new file mode 100644 index 0000000..cb36d20 --- /dev/null +++ b/frontend/components/DynamicForm.vue @@ -0,0 +1,102 @@ + + + diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts new file mode 100644 index 0000000..e22dcdb --- /dev/null +++ b/frontend/nuxt.config.ts @@ -0,0 +1,6 @@ +export default defineNuxtConfig({ + modules: ['@nuxt/ui'], + compatibilityDate: '2024-10-01', + devtools: { enabled: true }, + typescript: { strict: true } +}) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b80697f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/pages/admin/apps.vue b/frontend/pages/admin/apps.vue new file mode 100644 index 0000000..50cec89 --- /dev/null +++ b/frontend/pages/admin/apps.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/admin/entities/[id]/fields.vue b/frontend/pages/admin/entities/[id]/fields.vue new file mode 100644 index 0000000..ff2669d --- /dev/null +++ b/frontend/pages/admin/entities/[id]/fields.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/admin/entities/index.vue b/frontend/pages/admin/entities/index.vue new file mode 100644 index 0000000..0096645 --- /dev/null +++ b/frontend/pages/admin/entities/index.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/admin/forms.vue b/frontend/pages/admin/forms.vue new file mode 100644 index 0000000..6cd83aa --- /dev/null +++ b/frontend/pages/admin/forms.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/admin/workflows.vue b/frontend/pages/admin/workflows.vue new file mode 100644 index 0000000..11fcb87 --- /dev/null +++ b/frontend/pages/admin/workflows.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue new file mode 100644 index 0000000..457d4a0 --- /dev/null +++ b/frontend/pages/index.vue @@ -0,0 +1,16 @@ + diff --git a/frontend/pages/runtime/[entity]/[id]/edit.vue b/frontend/pages/runtime/[entity]/[id]/edit.vue new file mode 100644 index 0000000..877295d --- /dev/null +++ b/frontend/pages/runtime/[entity]/[id]/edit.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/runtime/[entity]/[id]/view.vue b/frontend/pages/runtime/[entity]/[id]/view.vue new file mode 100644 index 0000000..fa05006 --- /dev/null +++ b/frontend/pages/runtime/[entity]/[id]/view.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/pages/runtime/tickets/list.vue b/frontend/pages/runtime/tickets/list.vue new file mode 100644 index 0000000..b8c9f67 --- /dev/null +++ b/frontend/pages/runtime/tickets/list.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/pages/runtime/tickets/new.vue b/frontend/pages/runtime/tickets/new.vue new file mode 100644 index 0000000..d8e65e4 --- /dev/null +++ b/frontend/pages/runtime/tickets/new.vue @@ -0,0 +1,55 @@ + + + diff --git a/ops/DEPLOY_PROXMOX.md b/ops/DEPLOY_PROXMOX.md new file mode 100644 index 0000000..23c0e15 --- /dev/null +++ b/ops/DEPLOY_PROXMOX.md @@ -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