Release v0.1.4

This commit is contained in:
2026-07-02 15:01:18 +02:00
parent 6d391d13bd
commit a2053518d1
13 changed files with 788 additions and 67 deletions

View File

@@ -48,6 +48,13 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
The module repositories include root-level npm package manifests so git installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`.
The normal release path is automated by `scripts/push-release-tag.sh`: it bumps or accepts the target version, updates Python/WebUI/module manifest versions, commits/tags/pushes the module repositories first, regenerates `webui/package-lock.release.json`, and then commits/tags/pushes core. If the working tree has already been bumped, pass the current version explicitly:
```bash
cd /mnt/DATA/git/govoplan-core
scripts/push-release-tag.sh --version 0.1.2
```
### Release lockfile strategy
The supported release composition currently is the full Multi Seal Mail product: core plus files, mail, and campaign. Keep one committed full-product release lockfile at `webui/package-lock.release.json`, generated from `webui/package.release.json` in a clean release workspace. Development `package-lock.json` may continue to point at local `file:` dependencies.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.3"
version = "0.1.4"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"

136
scripts/launch-dev.sh Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${GOVOPLAN_CORE_ROOT:-/mnt/DATA/git/govoplan-core}"
WEBUI_ROOT="$ROOT/webui"
PYTHON="${PYTHON:-$ROOT/.venv/bin/python}"
NODE_BIN="${NODE_BIN:-/home/zemion/.nvm/versions/node/v22.22.3/bin}"
NPM="${NPM:-$NODE_BIN/npm}"
BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
OPEN_BROWSER="${OPEN_BROWSER:-1}"
LOG_DIR="$ROOT/runtime/dev-launcher"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
BACKEND_URL="http://$BACKEND_HOST:$BACKEND_PORT"
FRONTEND_URL="http://$FRONTEND_HOST:$FRONTEND_PORT"
backend_pid=""
frontend_pid=""
fail() {
printf 'launch-dev: %s\n' "$*" >&2
exit 1
}
port_is_free() {
"$PYTHON" - "$1" "$2" <<'PY'
import socket
import sys
host = sys.argv[1]
port = int(sys.argv[2])
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((host, port))
except OSError:
raise SystemExit(1)
PY
}
wait_for_url() {
"$PYTHON" - "$1" <<'PY'
import sys
import time
import urllib.request
url = sys.argv[1]
deadline = time.monotonic() + 60
last_error = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
if 200 <= response.status < 500:
raise SystemExit(0)
except Exception as exc: # noqa: BLE001 - printed only on timeout.
last_error = exc
time.sleep(1)
print(f"Timed out waiting for {url}: {last_error}", file=sys.stderr)
raise SystemExit(1)
PY
}
cleanup() {
if [ -n "${frontend_pid:-}" ] && kill -0 "$frontend_pid" 2>/dev/null; then
kill "$frontend_pid" 2>/dev/null || true
fi
if [ -n "${backend_pid:-}" ] && kill -0 "$backend_pid" 2>/dev/null; then
kill "$backend_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
[ -x "$PYTHON" ] || fail "Python virtualenv not found at $PYTHON. Run: cd $ROOT && ./.venv/bin/python -m pip install -r requirements-dev.txt"
[ -x "$NPM" ] || fail "npm not found at $NPM. Set NODE_BIN or NPM to your Node installation."
[ -f "$WEBUI_ROOT/package.json" ] || fail "WebUI package.json not found at $WEBUI_ROOT/package.json"
[ -d "$WEBUI_ROOT/node_modules" ] || fail "WebUI node_modules missing. Run: cd $WEBUI_ROOT && PATH=$NODE_BIN:\$PATH $NPM install"
mkdir -p "$LOG_DIR"
: > "$BACKEND_LOG"
: > "$FRONTEND_LOG"
port_is_free "$BACKEND_HOST" "$BACKEND_PORT" || fail "$BACKEND_URL is already in use"
port_is_free "$FRONTEND_HOST" "$FRONTEND_PORT" || fail "$FRONTEND_URL is already in use"
printf 'Starting GovOPlaN backend at %s\n' "$BACKEND_URL"
(
cd "$ROOT"
"$PYTHON" -m govoplan_core.devserver --host "$BACKEND_HOST" --port "$BACKEND_PORT"
) >"$BACKEND_LOG" 2>&1 &
backend_pid="$!"
printf 'Waiting for %s/health\n' "$BACKEND_URL"
wait_for_url "$BACKEND_URL/health" || {
tail -n 80 "$BACKEND_LOG" >&2 || true
fail "backend did not become healthy"
}
printf 'Starting GovOPlaN WebUI at %s\n' "$FRONTEND_URL"
(
cd "$WEBUI_ROOT"
PATH="$WEBUI_ROOT/node_modules/.bin:$NODE_BIN:$PATH" \
VITE_API_PROXY_TARGET="$BACKEND_URL" \
"$NPM" run dev
) >"$FRONTEND_LOG" 2>&1 &
frontend_pid="$!"
printf 'Waiting for %s\n' "$FRONTEND_URL"
wait_for_url "$FRONTEND_URL" || {
tail -n 80 "$FRONTEND_LOG" >&2 || true
fail "frontend did not become reachable"
}
if [ "$OPEN_BROWSER" = "1" ] && command -v xdg-open >/dev/null 2>&1; then
xdg-open "$FRONTEND_URL" >/dev/null 2>&1 || true
fi
cat <<EOF
GovOPlaN is running.
Web UI: $FRONTEND_URL
API: $BACKEND_URL/api/v1
Health: $BACKEND_URL/health
Logs:
Backend: $BACKEND_LOG
WebUI: $FRONTEND_LOG
Press Ctrl+C to stop both processes.
EOF
wait

View File

@@ -16,11 +16,14 @@ By default the script asks which version part to bump:
Options:
--bump <kind> Version bump: major, minor, subversion, or patch.
--version <x.y.z> Explicit target version instead of a bump.
--version <x.y.z> Explicit target version instead of a bump. May equal
the current repo versions when they were bumped already.
-r, --remote <name> Remote to push to. Defaults to origin.
-b, --branch <name> Branch to update in every repo. Defaults to each current branch.
-m, --message <text> Commit message. Defaults to "Release v<x.y.z>".
--tag-message <text> Annotated tag message. Defaults to the commit message.
--skip-release-lock Do not regenerate core webui/package-lock.release.json.
--npm <path> npm executable for lockfile generation.
-y, --yes Do not prompt before committing, tagging, and pushing.
-n, --dry-run Print intended actions without changing files or git state.
-h, --help Show this help.
@@ -60,6 +63,8 @@ COMMIT_MESSAGE=""
TAG_MESSAGE=""
DRY_RUN=0
YES=0
GENERATE_RELEASE_LOCK=1
NPM_BIN="${NPM:-}"
if [[ -z "${PYTHON:-}" ]]; then
if [[ -x "$ROOT/.venv/bin/python" ]]; then
@@ -69,6 +74,14 @@ if [[ -z "${PYTHON:-}" ]]; then
fi
fi
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
else
NPM_BIN="npm"
fi
fi
normalize_bump() {
local value="${1,,}"
case "$value" in
@@ -174,6 +187,133 @@ print(f"{name}: {old_version} -> {new_version}")
PYCODE
}
update_manifest_version() {
local repo="$1"
local project_name="$2"
local version="$3"
local manifest_path=""
case "$project_name" in
govoplan-core)
manifest_path="$repo/src/govoplan_core/access/manifest.py"
;;
govoplan-files)
manifest_path="$repo/src/govoplan_files/backend/manifest.py"
;;
govoplan-mail)
manifest_path="$repo/src/govoplan_mail/backend/manifest.py"
;;
govoplan-campaign)
manifest_path="$repo/src/govoplan_campaign/backend/manifest.py"
;;
*)
fail "unknown project for manifest version update: $project_name"
;;
esac
[[ -f "$manifest_path" ]] || fail "missing module manifest: $manifest_path"
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
from __future__ import annotations
import pathlib
import re
import sys
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
text = path.read_text()
text, count = re.subn(
r'(?m)^(\s*version=)["\'][^"\']+["\'](,?\s*)$',
rf'\1"{new_version}"\2',
text,
count=1,
)
if count != 1:
raise SystemExit(f"could not update ModuleManifest.version in {path}")
path.write_text(text)
PYCODE
}
update_webui_package() {
local repo="$1"
local project_name="$2"
local version="$3"
local package_path=""
local release_package_path="$repo/webui/package.release.json"
for package_path in "$repo/package.json" "$repo/webui/package.json"; do
[[ -f "$package_path" ]] || continue
"$PYTHON" - "$package_path" "$project_name" "$version" <<'PYCODE'
from __future__ import annotations
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
project_name = sys.argv[2]
new_version = sys.argv[3]
data = json.loads(path.read_text())
data["version"] = new_version
if project_name != "govoplan-core":
peers = data.setdefault("peerDependencies", {})
if "@govoplan/core-webui" in peers:
peers["@govoplan/core-webui"] = f"^{new_version}"
path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE
done
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
"$PYTHON" - "$release_package_path" "$version" <<'PYCODE'
from __future__ import annotations
import json
import pathlib
import re
import sys
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
data = json.loads(path.read_text())
data["version"] = new_version
dependencies = data.setdefault("dependencies", {})
for name, spec in list(dependencies.items()):
if name in {"@govoplan/files-webui", "@govoplan/mail-webui", "@govoplan/campaign-webui"}:
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE
fi
}
update_version_files() {
local repo="$1"
local version="$2"
local project_name="${PROJECT_NAMES[$repo]}"
update_pyproject "$repo" "$version"
update_manifest_version "$repo" "$project_name" "$version"
update_webui_package "$repo" "$project_name" "$version"
}
refresh_development_webui_lock() {
[[ -f "$ROOT/webui/package.json" ]] || return 0
[[ -f "$ROOT/webui/package-lock.json" ]] || return 0
run env PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" --prefix "$ROOT/webui" install --package-lock-only --ignore-scripts
}
generate_release_lock() {
if [[ "$GENERATE_RELEASE_LOCK" -eq 0 ]]; then
return
fi
[[ -x "$ROOT/scripts/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $ROOT/scripts/generate-release-lock.sh"
run "$ROOT/scripts/generate-release-lock.sh" --npm "$NPM_BIN"
}
print_command() {
printf '+'
printf ' %q' "$@"
@@ -276,6 +416,15 @@ while [[ $# -gt 0 ]]; do
TAG_MESSAGE="$2"
shift 2
;;
--skip-release-lock)
GENERATE_RELEASE_LOCK=0
shift
;;
--npm)
[[ $# -ge 2 ]] || fail "missing value for $1"
NPM_BIN="$2"
shift 2
;;
-y|--yes)
YES=1
shift
@@ -305,6 +454,9 @@ prompt_for_bump
if ! command -v "$PYTHON" >/dev/null 2>&1; then
fail "Python interpreter not found: $PYTHON"
fi
if ! command -v "$NPM_BIN" >/dev/null 2>&1; then
fail "npm executable not found: $NPM_BIN"
fi
declare -A PROJECT_NAMES
declare -A OLD_VERSIONS
@@ -349,6 +501,9 @@ fi
validate_version "$TARGET_VERSION" || fail "target version must be x.y.z: $TARGET_VERSION"
for repo in "${REPOS[@]}"; do
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
continue
fi
if ! version_gt "$TARGET_VERSION" "${OLD_VERSIONS[$repo]}"; then
fail "target version $TARGET_VERSION must be greater than ${OLD_VERSIONS[$repo]} for ${PROJECT_NAMES[$repo]}"
fi
@@ -382,6 +537,11 @@ echo " version: $TARGET_VERSION"
echo " tag: $TAG"
echo " remote: $REMOTE"
echo " commit message: $COMMIT_MESSAGE"
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
echo " release lock: regenerate core webui/package-lock.release.json after module tags are pushed"
else
echo " release lock: skipped"
fi
echo
for repo in "${REPOS[@]}"; do
@@ -394,13 +554,15 @@ confirm_release
for repo in "${REPOS[@]}"; do
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Would update $repo/pyproject.toml to $TARGET_VERSION"
echo "Would update version files in $repo to $TARGET_VERSION"
else
update_pyproject "$repo" "$TARGET_VERSION"
update_version_files "$repo" "$TARGET_VERSION"
fi
done
for repo in "${REPOS[@]}"; do
refresh_development_webui_lock
for repo in "${REPOS[@]:0:3}"; do
run git -C "$repo" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
@@ -413,10 +575,24 @@ for repo in "${REPOS[@]}"; do
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
done
for repo in "${REPOS[@]}"; do
for repo in "${REPOS[@]:0:3}"; do
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
done
generate_release_lock
run git -C "$ROOT" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$ROOT" diff --cached --quiet; then
fail "no staged changes for ${PROJECT_NAMES[$ROOT]} after version bump"
fi
fi
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry run complete for $TAG across all GovOPlaN repos."
else

View File

@@ -107,7 +107,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
manifest = ModuleManifest(
id="access",
name="Access",
version="0.1.2",
version="0.1.4",
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),

View File

@@ -473,33 +473,44 @@ def tenant_owner_user_ids(session: Session, tenant_id: str) -> set[str]:
.all()
)
owner_ids: set[str] = set()
user_ids = [user.id for user in users]
if not user_ids:
return owner_ids
roles_by_user_id: dict[str, list[Role]] = {user_id: [] for user_id in user_ids}
direct_role_rows = (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id.in_(user_ids),
Role.tenant_id == tenant_id,
)
.all()
)
for user_id, role in direct_role_rows:
roles_by_user_id.setdefault(user_id, []).append(role)
group_role_rows = (
session.query(UserGroupMembership.user_id, Role)
.join(GroupRoleAssignment, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Role, GroupRoleAssignment.role_id == Role.id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
Role.tenant_id == tenant_id,
)
.all()
)
for user_id, role in group_role_rows:
roles_by_user_id.setdefault(user_id, []).append(role)
for user in users:
direct_roles = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id == user.id,
Role.tenant_id == tenant_id,
)
.all()
)
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id == user.id,
Group.is_active.is_(True),
Role.tenant_id == tenant_id,
)
.all()
)
effective = [
scope
for role in direct_roles + group_roles
for role in roles_by_user_id.get(user.id, [])
for scope in (role.permissions or [])
]
if scopes_grant(effective, "admin:roles:write") and scopes_grant(effective, "campaign:send"):

View File

@@ -71,6 +71,123 @@ class ApiSmokeTests(unittest.TestCase):
payload = response.json()
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login()
payload = {
"name": "ERP export",
"columnCount": 3,
"headers": ["email", "name", "field.customer_id"],
"normalizedHeaders": ["email", "name", "field_customer_id"],
"orderedHeaderFingerprint": "ordered-123",
"unorderedHeaderFingerprint": "unordered-123",
"delimiter": ";",
"headerRows": 1,
"quoted": True,
"valueSeparators": ",;|",
"mappings": [
{"columnIndex": 0, "kind": "to"},
{"columnIndex": 1, "kind": "name"},
{"columnIndex": 2, "kind": "new_field", "newFieldName": "customer_id"},
],
}
created = self.client.post("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers, json=payload)
self.assertEqual(created.status_code, 201, created.text)
created_body = created.json()
self.assertEqual(created_body["name"], "ERP export")
self.assertEqual(created_body["columnCount"], 3)
self.assertEqual(created_body["mappings"][2]["newFieldName"], "customer_id")
profile_id = created_body["id"]
listed = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
self.assertEqual(listed.status_code, 200, listed.text)
self.assertEqual([profile["id"] for profile in listed.json()["profiles"]], [profile_id])
updated_payload = {**payload, "name": "ERP export updated", "valueSeparators": "|"}
updated = self.client.put(
f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}",
headers=headers,
json=updated_payload,
)
self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["name"], "ERP export updated")
self.assertEqual(updated.json()["valueSeparators"], "|")
deleted = self.client.delete(f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 204, deleted.text)
listed_after_delete = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
self.assertEqual(listed_after_delete.status_code, 200, listed_after_delete.text)
self.assertEqual(listed_after_delete.json()["profiles"], [])
def test_campaign_entries_store_recipient_import_provenance(self) -> None:
headers, _ = self._login()
campaign_json = {
"version": "1.0",
"campaign": {"id": "import-provenance", "name": "Import provenance", "mode": "test"},
"fields": [{"name": "department", "type": "string", "required": False}],
"global_values": {},
"server": {},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Hello", "text": "Hello"},
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
"fields": {"department": "Finance"},
}
],
"imports": [
{
"id": "recipient-import-test",
"imported_at": "2026-06-26T12:00:00Z",
"mode": "replace",
"source_type": "xlsx",
"filename": "recipients.xlsx",
"sheet_name": "Recipients",
"encoding": None,
"delimiter": None,
"header_rows": 1,
"quoted": None,
"value_separators": ",;|",
"rows_total": 1,
"valid_rows": 1,
"invalid_rows": 0,
"imported_rows": 1,
"field_names_created": [],
"attachment_patterns": 0,
"mapping": [
{"column_index": 0, "header": "email", "kind": "to"},
{"column_index": 1, "header": "field.department", "kind": "field", "field_name": "department"},
],
}
],
},
"validation_policy": {"missing_email": "block", "template_error": "block"},
"delivery": {"rate_limit": {"messages_per_minute": 60}},
"status_tracking": {"enabled": True},
}
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
self.assertEqual(created.status_code, 200, created.text)
campaign_id = created.json()["campaign"]["id"]
version_id = created.json()["version"]["id"]
detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers)
self.assertEqual(detail.status_code, 200, detail.text)
stored_import = detail.json()["raw_json"]["entries"]["imports"][0]
self.assertEqual(stored_import["source_type"], "xlsx")
self.assertEqual(stored_import["sheet_name"], "Recipients")
self.assertEqual(stored_import["mapping"][1]["field_name"], "department")
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
self.assertEqual(validated.status_code, 200, validated.text)
self.assertTrue(validated.json()["ok"], validated.text)
def _create_built_delivery_campaign(
self,
headers: dict[str, str],
@@ -587,6 +704,7 @@ class ApiSmokeTests(unittest.TestCase):
campaign_files = self.client.get("/api/v1/files", headers=headers, params={"campaign_id": campaign_id})
self.assertEqual(campaign_files.status_code, 200, campaign_files.text)
self.assertEqual({item["id"] for item in campaign_files.json()["files"]}, {first_file["id"], second_file["id"]})
self.assertTrue(all(item["shares"] for item in campaign_files.json()["files"]))
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
@@ -1744,14 +1862,40 @@ class ApiSmokeTests(unittest.TestCase):
set_failures(smtp_reject_recipients_containing=None)
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "smtp_accepted")
self.assertIn("reject-me@example.org", job.last_error or "")
job_id = job.id
attempt_count = job.attempt_count
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
self.assertEqual(attempt.status, "smtp_accepted_with_refusals")
self.assertIn("reject-me@example.org", attempt.smtp_response or "")
retry_accepted = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
headers=headers,
json={"version_id": version_id, "job_ids": [job_id], "include_permanent": True, "enqueue_celery": False},
)
self.assertEqual(retry_accepted.status_code, 200, retry_accepted.text)
self.assertEqual(retry_accepted.json()["result"]["selected_count"], 0)
self.assertIn("not an explicit retry candidate", retry_accepted.json()["result"]["skipped"][0]["reason"])
unattempted_accepted = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/send-unattempted",
headers=headers,
json={"version_id": version_id, "job_ids": [job_id], "enqueue_celery": False},
)
self.assertEqual(unattempted_accepted.status_code, 200, unattempted_accepted.text)
self.assertEqual(unattempted_accepted.json()["result"]["selected_count"], 0)
with SessionLocal() as session:
direct_result = send_campaign_job(session, job_id=job_id, use_rate_limit=False)
self.assertEqual(direct_result.status, "already_accepted")
job = session.get(CampaignJob, job_id)
self.assertEqual(job.attempt_count, attempt_count)
def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None:
headers, _ = self._login()
campaign_id, version_id = self._create_built_delivery_campaign(
@@ -1767,6 +1911,11 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(queued.status_code, 200, queued.text)
self.assertEqual(queued.json()["queued_count"], 2)
queued_summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
self.assertEqual(queued_summary.status_code, 200, queued_summary.text)
self.assertEqual(queued_summary.json()["cards"]["queued_or_active"], 2)
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
@@ -1839,6 +1988,11 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(filtered_page.json()["total_unfiltered"], 2)
self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1)
summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
self.assertEqual(summary.status_code, 200, summary.text)
self.assertEqual(summary.json()["cards"]["outcome_unknown"], 1)
self.assertEqual(summary.json()["status_counts"]["send"]["outcome_unknown"], 1)
detail = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}",
headers=headers,
@@ -1855,6 +2009,12 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(resolved.status_code, 200, resolved.text)
self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary")
reconciled_report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers, params={"version_id": version_id})
self.assertEqual(reconciled_report.status_code, 200, reconciled_report.text)
self.assertEqual(reconciled_report.json()["cards"]["outcome_unknown"], 0)
self.assertEqual(reconciled_report.json()["cards"]["failed"], 1)
self.assertEqual(reconciled_report.json()["status_counts"]["send"]["failed_temporary"], 1)
retry = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
headers=headers,

View File

@@ -80,6 +80,8 @@ class DatabaseMigrationTests(unittest.TestCase):
}
system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")}
import_profile_columns = {column["name"] for column in inspector.get_columns("campaign_recipient_import_mapping_profiles")}
import_profile_indexes = {index["name"] for index in inspector.get_indexes("campaign_recipient_import_mapping_profiles")}
account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one()
user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one()
system_owner_count = connection.execute(text(
@@ -114,8 +116,13 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("governance_templates", tables)
self.assertIn("governance_template_assignments", tables)
self.assertIn("campaign_shares", tables)
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
self.assertIn("owner_user_id", campaign_columns)
self.assertIn("owner_group_id", campaign_columns)
self.assertIn("ordered_header_fingerprint", import_profile_columns)
self.assertIn("unordered_header_fingerprint", import_profile_columns)
self.assertIn("mappings", import_profile_columns)
self.assertIn("ix_recipient_import_profiles_ordered_fp", import_profile_indexes)
self.assertIn("scope", audit_columns)
self.assertIn("ix_audit_log_scope", audit_indexes)
self.assertIn("ix_audit_log_scope_created_at", audit_indexes)
@@ -409,4 +416,3 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("uq_file_folders_active_group_path", folder_indexes)
finally:
engine.dispose()

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
@@ -20,6 +20,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -32,16 +33,15 @@
},
"../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
"read-excel-file": "^9.2.0"
},
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -55,9 +55,9 @@
},
"../../govoplan-files/webui": {
"name": "@govoplan/files-webui",
"version": "0.1.2",
"version": "0.1.4",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
@@ -74,9 +74,12 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.2",
"version": "0.1.4",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -1363,6 +1366,16 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.6"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1554,6 +1567,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1579,6 +1599,13 @@
"node": ">=6.9.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1658,6 +1685,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@@ -1790,6 +1824,21 @@
"react-dom": ">=18"
}
},
"node_modules/read-excel-file": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz",
"integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"fflate": "^0.8.3",
"unzipper-esm": "^0.13.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1900,6 +1949,20 @@
"node": ">=14.17"
}
},
"node_modules/unzipper-esm": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
"integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -1,16 +1,16 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2"
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -710,14 +710,13 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#39ad3500e2548e74c38a3c75c3cefa85cbf4a00b",
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#fb6fb67d451858cca891d8a2fe5b6b8790f75f0d",
"dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2"
"read-excel-file": "^9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -730,10 +729,10 @@
}
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#b5f7c43028e7a2b5cc261835b66ec0923cb30970",
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#ee5b06b1e7d60f458ceb9ed943369c55201698ce",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
@@ -749,10 +748,10 @@
}
},
"node_modules/@govoplan/mail-webui": {
"version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3cd8c45c42d76265e895466e6ce67ad67221c8df",
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3743b236130d6684b5e8d406f5070e3ff9e33d07",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -1266,6 +1265,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"license": "MIT",
"engines": {
"node": ">=14.6"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1447,6 +1455,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1470,6 +1484,12 @@
"node": ">=6.9.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1542,6 +1562,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@@ -1665,6 +1691,20 @@
"react-dom": ">=18"
}
},
"node_modules/read-excel-file": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz",
"integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"fflate": "^0.8.3",
"unzipper-esm": "^0.13.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1768,6 +1808,19 @@
"node": ">=14.17"
}
},
"node_modules/unzipper-esm": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
"integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -25,18 +25,19 @@
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
},
"dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -22,9 +22,9 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2"
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4"
},
"devDependencies": {
"lucide-react": "^0.555.0",

View File

@@ -317,8 +317,116 @@ export type FilesFolderTreeProps = {
depth?: number;
};
export type FilesOwnerType = "user" | "group";
export type FilesFileSpace = {
id: string;
label: string;
owner_type: FilesOwnerType;
owner_id: string;
description?: string | null;
};
export type FilesFileShare = {
id: string;
target_type: string;
target_id: string;
permission: string;
created_at: string;
revoked_at?: string | null;
};
export type FilesManagedFile = {
id: string;
tenant_id: string;
owner_type: FilesOwnerType;
owner_id: string;
display_path: string;
filename: string;
description?: string | null;
size_bytes: number;
content_type?: string | null;
checksum_sha256: string;
version_id: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
audit_relevant: boolean;
metadata?: Record<string, unknown> | null;
shares?: FilesFileShare[];
};
export type FilesFileFolder = {
id: string;
tenant_id: string;
owner_type: FilesOwnerType;
owner_id: string;
path: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type FilesFileSpacesResponse = { spaces: FilesFileSpace[] };
export type FilesFileListResponse = { files: FilesManagedFile[] };
export type FilesFileFoldersResponse = { folders: FilesFileFolder[] };
export type FilesPatternResolveResponse = {
patterns: { pattern: string; matches: FilesManagedFile[] }[];
unmatched: FilesManagedFile[];
};
export type FilesFileBulkShareResponse = { shares: FilesFileShare[]; shared_count: number };
export type FilesManagedFileLinkTarget = {
type: string;
id: string;
label?: string;
permission?: string;
};
export type FilesManagedFileChooserMode = "folder" | "attachment";
export type FilesManagedAttachmentChoiceMode = "file" | "pattern";
export type FilesManagedFolderSelection = {
space: FilesFileSpace;
folderPath: string;
source: string;
};
export type FilesManagedAttachmentSelection = FilesManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: FilesManagedAttachmentChoiceMode;
};
export type FilesManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
mode: FilesManagedFileChooserMode;
storageScope?: string;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
previewContext?: Record<string, string>;
linkTarget?: FilesManagedFileLinkTarget;
renderPatternPreview?: (pattern: string, context?: Record<string, string>) => string;
onClose: () => void;
onSelectFolder?: (selection: FilesManagedFolderSelection) => void;
onSelectAttachment?: (selection: FilesManagedAttachmentSelection) => void;
};
export type FilesFileExplorerUiCapability = {
FolderTree: ComponentType<FilesFolderTreeProps>;
ManagedFileChooser?: ComponentType<FilesManagedFileChooserProps>;
listFileSpaces?: (settings: ApiSettings) => Promise<FilesFileSpacesResponse>;
listFiles?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string }) => Promise<FilesFileListResponse>;
listFolders?: (settings: ApiSettings, params: { owner_type: FilesOwnerType; owner_id: string }) => Promise<FilesFileFoldersResponse>;
resolveFilePatterns?: (
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: FilesOwnerType; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
) => Promise<FilesPatternResolveResponse>;
shareFilesWithTarget?: (settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget) => Promise<FilesFileBulkShareResponse>;
};
export type PlatformFrontendRouteInfo = {