From a2053518d1a5372c68c09623cb2d59ec3ec560cf Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 2 Jul 2026 15:01:18 +0200 Subject: [PATCH] Release v0.1.4 --- docs/RELEASE_DEPENDENCIES.md | 7 + pyproject.toml | 2 +- scripts/launch-dev.sh | 136 ++++++++++++++++++++ scripts/push-release-tag.sh | 186 ++++++++++++++++++++++++++- src/govoplan_core/access/manifest.py | 2 +- src/govoplan_core/admin/service.py | 59 +++++---- tests/test_api_smoke.py | 160 +++++++++++++++++++++++ tests/test_database_migrations.py | 8 +- webui/package-lock.json | 83 ++++++++++-- webui/package-lock.release.json | 85 +++++++++--- webui/package.json | 11 +- webui/package.release.json | 8 +- webui/src/types.ts | 108 ++++++++++++++++ 13 files changed, 788 insertions(+), 67 deletions(-) create mode 100644 scripts/launch-dev.sh diff --git a/docs/RELEASE_DEPENDENCIES.md b/docs/RELEASE_DEPENDENCIES.md index 187fb17..7992f1d 100644 --- a/docs/RELEASE_DEPENDENCIES.md +++ b/docs/RELEASE_DEPENDENCIES.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 3bb73a4..b57f85e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/launch-dev.sh b/scripts/launch-dev.sh new file mode 100644 index 0000000..276b0e2 --- /dev/null +++ b/scripts/launch-dev.sh @@ -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 < Version bump: major, minor, subversion, or patch. - --version Explicit target version instead of a bump. + --version Explicit target version instead of a bump. May equal + the current repo versions when they were bumped already. -r, --remote Remote to push to. Defaults to origin. -b, --branch Branch to update in every repo. Defaults to each current branch. -m, --message Commit message. Defaults to "Release v". --tag-message Annotated tag message. Defaults to the commit message. + --skip-release-lock Do not regenerate core webui/package-lock.release.json. + --npm 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 diff --git a/src/govoplan_core/access/manifest.py b/src/govoplan_core/access/manifest.py index b6a8b7d..cd71561 100644 --- a/src/govoplan_core/access/manifest.py +++ b/src/govoplan_core/access/manifest.py @@ -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), diff --git a/src/govoplan_core/admin/service.py b/src/govoplan_core/admin/service.py index e48ae96..cf6da50 100644 --- a/src/govoplan_core/admin/service.py +++ b/src/govoplan_core/admin/service.py @@ -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"): diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 695d059..2fe7816 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -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, diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index d18b057..7b3570d 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -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() - diff --git a/webui/package-lock.json b/webui/package-lock.json index 231be50..d9edd22 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 1f1e8a7..1a24f12 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -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", diff --git a/webui/package.json b/webui/package.json index ec136d3..2077791 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" }, diff --git a/webui/package.release.json b/webui/package.release.json index a4592eb..51d7f14 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -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", diff --git a/webui/src/types.ts b/webui/src/types.ts index 45ee1f1..e9e577d 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -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 | 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; + linkTarget?: FilesManagedFileLinkTarget; + renderPatternPreview?: (pattern: string, context?: Record) => string; + onClose: () => void; + onSelectFolder?: (selection: FilesManagedFolderSelection) => void; + onSelectAttachment?: (selection: FilesManagedAttachmentSelection) => void; +}; + export type FilesFileExplorerUiCapability = { FolderTree: ComponentType; + ManagedFileChooser?: ComponentType; + listFileSpaces?: (settings: ApiSettings) => Promise; + listFiles?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string }) => Promise; + listFolders?: (settings: ApiSettings, params: { owner_type: FilesOwnerType; owner_id: string }) => Promise; + 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; + shareFilesWithTarget?: (settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget) => Promise; }; export type PlatformFrontendRouteInfo = {