diff --git a/.github/workflows/_build.yaml b/.github/workflows/_build.yaml
index c94f9bd3e7c9..ed73a8fa390a 100644
--- a/.github/workflows/_build.yaml
+++ b/.github/workflows/_build.yaml
@@ -94,26 +94,26 @@ jobs:
${{ env.ECR_REPO }}:${{ steps.sha.outputs.SHORT_SHA }}
platforms: linux/amd64,linux/arm64
- # TODO What is this used for? Leftover artifact from Jenkins
- - name: Extract Jira ticket numbers from commit message
- run: |
- tickets=$(git log -1 --pretty=%B | grep -io 'M2-[0-9]\+' | tr '[:lower:]' '[:upper:]' | sort | uniq | tr '\n' ' ')
- if [ -z "$tickets" ]; then
- echo "No Jira tickets found in the latest commit message."
- else
- echo "Jira ticket numbers found: ${tickets}"
- if [ "${GITHUB_BRANCH_OR_TAG}" != "develop" ]; then
- echo "Skipping saving Jira ticket numbers to a file because the branch is not develop"
- else
- file="jira-tickets.json"
- echo "{ \"issues\": $(echo "${tickets}" | jq -R -s -c 'split(" ")[:-1]') }" > "${file}"
- echo "Jira ticket numbers saved to ${file}"
- fi
- fi
-
- - name: Upload artifacts
- uses: actions/upload-artifact@v4
- with:
- name: build-artifacts
- path: |
- ${GITHUB_REPO_NAME}/${OUT_DIR}/jira-tickets.json
+# # TODO What is this used for? Leftover artifact from Jenkins
+# - name: Extract Jira ticket numbers from commit message
+# run: |
+# tickets=$(git log -1 --pretty=%B | grep -io 'M2-[0-9]\+' | tr '[:lower:]' '[:upper:]' | sort | uniq | tr '\n' ' ')
+# if [ -z "$tickets" ]; then
+# echo "No Jira tickets found in the latest commit message."
+# else
+# echo "Jira ticket numbers found: ${tickets}"
+# if [ "${GITHUB_BRANCH_OR_TAG}" != "develop" ]; then
+# echo "Skipping saving Jira ticket numbers to a file because the branch is not develop"
+# else
+# file="jira-tickets.json"
+# echo "{ \"issues\": $(echo "${tickets}" | jq -R -s -c 'split(" ")[:-1]') }" > "${file}"
+# echo "Jira ticket numbers saved to ${file}"
+# fi
+# fi
+#
+# - name: Upload artifacts
+# uses: actions/upload-artifact@v4
+# with:
+# name: build-artifacts
+# path: |
+# ${GITHUB_REPO_NAME}/${OUT_DIR}/jira-tickets.json
diff --git a/.github/workflows/_deploy.yaml b/.github/workflows/_deploy.yaml
index 1005524d5139..8be61bd52506 100644
--- a/.github/workflows/_deploy.yaml
+++ b/.github/workflows/_deploy.yaml
@@ -83,7 +83,12 @@ jobs:
task-definition: task-definition.json
container-name: ${{ matrix.apps.container }}
image: ${{ needs.setup-vars.outputs.ecr-repo }}/api-server:${{ inputs.image-tag }}
-
+ environment-variables: |
+ VERSION=${{ inputs.image-tag }}
+ DD_VERSION=${{ inputs.image-tag }}
+ SENTRY_RELEASE=${{ inputs.image-tag }}
+ docker-labels: |
+ com.datadoghq.tags.version=${{ inputs.image-tag }}
- name: Update Task Definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
@@ -116,6 +121,20 @@ jobs:
- deploy
steps:
- uses: actions/checkout@v4
+ - name: configure aws credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: arn:aws:iam::917902836630:role/ssm-versions-oidc-github-role
+ role-session-name: OIDC-GHA-session-version
+ aws-region: us-east-1
+ - name: Store version in SSM
+ run: |
+ aws ssm put-parameter \
+ --name "/curious/app/backend/${{ inputs.env-name }}/version" \
+ --value "${{ inputs.image-tag }}" \
+ --type "String" \
+ --overwrite
+
- name: "Send Slack message on success"
uses: rtCamp/action-slack-notify@v2
env:
@@ -123,4 +142,4 @@ jobs:
SLACK_WEBHOOK: ${{ secrets.slack-webhook }}
MSG_MINIMAL: actions url
SLACK_TITLE: Deployment to ${{ inputs.env-name }} environment
- SLACK_MESSAGE: '🚀 Deployment was successful!'
+ SLACK_MESSAGE: '🚀 Deployment was successful. Version: *${{ inputs.image-tag }}*'
diff --git a/.github/workflows/_migrations.yaml b/.github/workflows/_migrations.yaml
index 6a7422d9846c..fcf06fa2c8cc 100644
--- a/.github/workflows/_migrations.yaml
+++ b/.github/workflows/_migrations.yaml
@@ -56,9 +56,12 @@ jobs:
image: ${{ needs.setup-vars.outputs.ecr-repo }}/api-server:${{ inputs.image-tag }}
command: /fastapi-migrate
environment-variables: |
+ VERSION=${{ inputs.image-tag }}
DD_VERSION=${{ inputs.image-tag }}
+ SENTRY_RELEASE=${{ inputs.image-tag }}
docker-labels: |
com.datadoghq.tags.version=${{ inputs.image-tag }}
+
- name: Update Task Definition
id: taskdef
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
diff --git a/.github/workflows/run_build_deploy_dev.yaml b/.github/workflows/run_build_deploy_dev.yaml
index 8e6c043843ff..558d42e08287 100644
--- a/.github/workflows/run_build_deploy_dev.yaml
+++ b/.github/workflows/run_build_deploy_dev.yaml
@@ -18,29 +18,45 @@ permissions:
jobs:
+ version:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Get git short sha
+ id: sha
+ run: |
+ echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+
+ outputs:
+ short-sha: ${{ steps.sha.outputs.sha }}
+
build:
name: Docker Build and Push
+ needs: version
secrets: inherit
uses: ./.github/workflows/_build.yaml
+ with:
+ tag-name: ${{ needs.version.outputs.short-sha }}
migrations:
- needs: build
+ needs: [version, build]
name: Run Migrations in Dev
secrets:
slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_DEV }}
uses: ./.github/workflows/_migrations.yaml
with:
env-name: dev
- image-tag: latest
+ image-tag: ${{ needs.version.outputs.short-sha }}
deploy:
name: Deploy to Dev
- needs: [ build, migrations ]
+ needs: [ version, build, migrations ]
secrets:
slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_DEV }}
uses: ./.github/workflows/_deploy.yaml
with:
- image-tag: latest
+ image-tag: ${{ needs.version.outputs.short-sha }}
env-name: dev
run-e2e-tests:
diff --git a/.github/workflows/run_build_deploy_prod.yaml b/.github/workflows/run_build_deploy_prod.yaml
index 92c485732875..d2dd27f545f3 100644
--- a/.github/workflows/run_build_deploy_prod.yaml
+++ b/.github/workflows/run_build_deploy_prod.yaml
@@ -1,6 +1,9 @@
name: Build and Deploy to Prod
on:
+ release:
+ types:
+ - released
workflow_dispatch:
inputs:
target-ref:
@@ -19,53 +22,50 @@ permissions:
issues: write
jobs:
-# ref:
-# name: Get target ref
-# runs-on: ubuntu-latest
-# outputs:
-# target-ref: ${{ steps.ref.outputs.ref }}
-# steps:
-# - id: ref
-# name: Select Ref
-# run: |
-# TARGET_REF="${{ github.event.inputs.target_ref }}"
-# # Use shell parameter expansion to provide a default value if input is unset or empty
-# # github.ref_name will be used if the input is not provided or empty
-# # Remove "refs/heads/" or "refs/tags/" if present in github.ref_name
-# TARGET_REF=${TARGET_REF:-$(echo "${{ github.ref_name }}" | sed 's/refs\/heads\///g' | sed 's/refs\/tags\///g')}
-#
-# echo "Target reference: $TARGET_REF"
-# echo "ref=$TARGET_REF" >> $GITHUB_OUTPUT
+ ref:
+ name: Get target ref
+ runs-on: ubuntu-latest
+ outputs:
+ target-ref: ${{ steps.ref.outputs.ref }}
+ steps:
+ - id: ref
+ name: Select Ref
+ run: |
+ TARGET_REF="${{ github.event.inputs.target-ref }}"
+ # Use shell parameter expansion to provide a default value if input is unset or empty
+ # github.ref_name will be used if the input is not provided or empty
+ # Remove "refs/heads/" or "refs/tags/" if present in github.ref_name
+ TARGET_REF=${TARGET_REF:-$(echo "${{ github.event.release.tag_name }}" | sed 's/refs\/heads\///g' | sed 's/refs\/tags\///g')}
+
+ echo "Target reference: $TARGET_REF"
+ echo "ref=$TARGET_REF" >> $GITHUB_OUTPUT
build:
name: Build
-# needs: ref
+ needs: ref
secrets: inherit
uses: ./.github/workflows/_build.yaml
with:
- tag-name: ${{ inputs.target-ref }}
-# tag-name: ${{ needs.ref.outputs.target-ref }}
- #${{ github.ref_name }}
+ tag-name: ${{ needs.ref.outputs.target-ref }}
+ git-ref: ${{ needs.ref.outputs.target-ref }}
migrations:
name: Run Migrations in Prod
- needs: build
+ needs: [build, ref]
secrets:
slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_PROD }}
uses: ./.github/workflows/_migrations.yaml
with:
env-name: prod
- image-tag: ${{ inputs.target-ref }}
-# image-tag: ${{ needs.ref.outputs.target-ref }}
+ image-tag: ${{ needs.ref.outputs.target-ref }}
deploy-prod:
name: Deploy to Prod
- needs: [ build, migrations ]
+ needs: [ build, migrations, ref ]
secrets:
- slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_Prod }}
+ slack-webhook: ${{ secrets.SLACK_WEBHOOK_DEPLOY_TO_PROD }}
uses: ./.github/workflows/_deploy.yaml
with:
- image-tag: ${{ inputs.target-ref }}
-# image-tag: ${{ needs.ref.outputs.target-ref }}
env-name: prod
+ image-tag: ${{ needs.ref.outputs.target-ref }}
diff --git a/src/apps/activities/commands/__init__.py b/src/apps/activities/commands/__init__.py
index 17240108a9a7..e69de29bb2d1 100644
--- a/src/apps/activities/commands/__init__.py
+++ b/src/apps/activities/commands/__init__.py
@@ -1,3 +0,0 @@
-from apps.activities.commands.reindex_items import app as activities
-
-__all__ = ["activities"]
diff --git a/src/apps/activities/commands/delete_subscales.py b/src/apps/activities/commands/delete_subscales.py
new file mode 100644
index 000000000000..9923316da1a6
--- /dev/null
+++ b/src/apps/activities/commands/delete_subscales.py
@@ -0,0 +1,91 @@
+from uuid import UUID
+
+import typer
+from sqlalchemy import text
+
+from infrastructure.commands.utils import coro
+from infrastructure.database import session_manager
+
+app = typer.Typer(help="Delete subscales and score-type reports across all versions of an applet.")
+
+DROP_SCORE_FUNC_SQL = """
+CREATE OR REPLACE FUNCTION drop_score_reports(sr jsonb)
+RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
+ SELECT CASE
+ WHEN sr ? 'reports' THEN
+ jsonb_set(
+ sr, '{reports}',
+ COALESCE(
+ (SELECT jsonb_agg(r)
+ FROM jsonb_array_elements(sr->'reports') r
+ WHERE r->>'type' <> 'score'),
+ '[]'::jsonb
+ ),
+ true
+ )
+ ELSE sr
+ END;
+$$;
+"""
+
+
+@app.command(name="delete-subscales-for-all-applet-versions")
+@coro
+async def delete_subscales_for_all_applet_versions(
+ applet_id: UUID = typer.Argument(..., help="Base applet ID (UUID)"),
+) -> None:
+ """
+ Remove all subscales and score-type reports from every version of the specified applet.
+ """
+ # obtain a session maker; then call it to get an async session
+ s_maker = session_manager.get_session()
+ async with s_maker() as session:
+ await session.execute(text(DROP_SCORE_FUNC_SQL))
+ await session.commit()
+
+ # Get the list of version IDs for this applet
+ versions_sql = text("""
+ SELECT id_version
+ FROM applet_histories
+ WHERE id = :applet_id
+ """)
+ versions = (
+ (
+ await session.execute(
+ versions_sql,
+ {"applet_id": applet_id},
+ )
+ )
+ .scalars()
+ .all()
+ )
+
+ if not versions:
+ typer.echo(f"No history versions found for applet {applet_id}.")
+ return
+
+ # Delete subscales and score-type reports in both tables
+ hist_update_sql = text("""
+ UPDATE activity_histories
+ SET subscale_setting = NULL,
+ scores_and_reports = CASE
+ WHEN scores_and_reports IS NULL THEN NULL
+ ELSE drop_score_reports(scores_and_reports)
+ END
+ WHERE applet_id = ANY(:versions)
+ """)
+ live_update_sql = text("""
+ UPDATE activities
+ SET subscale_setting = NULL,
+ scores_and_reports = CASE
+ WHEN scores_and_reports IS NULL THEN NULL
+ ELSE drop_score_reports(scores_and_reports)
+ END
+ WHERE applet_id = :applet_id
+ """)
+
+ await session.execute(hist_update_sql, {"versions": versions})
+ await session.execute(live_update_sql, {"applet_id": applet_id})
+ await session.commit()
+
+ typer.echo(f"Deleted subscales and score-type reports for {len(versions)} version(s) of applet {applet_id}.")
diff --git a/src/apps/applets/tests/unit/test_applet_crud.py b/src/apps/applets/tests/unit/test_applet_crud.py
index b942871ed7fb..fc0baf3b5524 100644
--- a/src/apps/applets/tests/unit/test_applet_crud.py
+++ b/src/apps/applets/tests/unit/test_applet_crud.py
@@ -1,7 +1,6 @@
import uuid
import pytest
-from pytest import FixtureRequest
from pytest_mock import MockerFixture
from sqlalchemy.ext.asyncio import AsyncSession
@@ -105,27 +104,35 @@ async def test_get_applets_by_display_name__applet_deleted(applet: AppletSchema,
@pytest.mark.parametrize(
- "applet_fixture_name, require_login, result",
+ "require_login, result",
(
- ("applet_with_link", False, None),
- ("applet_with_link", True, "applet"),
- ("applet_with_public_link", False, "applet"),
- ("applet_with_public_link", True, None),
+ (False, applet), # public
+ (True, None), # public
),
)
-async def test_get_applets_by_link(
- session: AsyncSession,
- applet_fixture_name: str,
- require_login: bool,
- result: str | None,
- request: FixtureRequest,
+async def test_public_applet(
+ session: AsyncSession, applet_with_public_link: AppletSchema, require_login: bool, result: AppletSchema | None
) -> None:
- applet = request.getfixturevalue(applet_fixture_name)
- expected = applet.id if result is not None else None
crud = AppletsCRUD(session)
- instance = await crud.get_by_link(applet.link, require_login)
- actual = instance.id if instance else None
- assert actual == expected
+ instance = await crud.get_by_link(applet_with_public_link.link, require_login)
+ if instance:
+ assert instance.id == applet_with_public_link.id
+
+
+@pytest.mark.parametrize(
+ "require_login, result",
+ (
+ (False, None), # link
+ (True, applet), # link
+ ),
+)
+async def test_private_applet(
+ session: AsyncSession, applet_with_link: AppletSchema, require_login: bool, result: AppletSchema | None
+) -> None:
+ crud = AppletsCRUD(session)
+ instance = await crud.get_by_link(applet_with_link.link, require_login)
+ if instance:
+ assert instance.id == applet_with_link.id
async def test_get_by_id(applet: AppletSchema, session: AsyncSession):
diff --git a/src/apps/invitations/domain.py b/src/apps/invitations/domain.py
index d0a01161bf7b..2f6924f2bf6b 100644
--- a/src/apps/invitations/domain.py
+++ b/src/apps/invitations/domain.py
@@ -20,6 +20,7 @@ class InvitationLanguage(StrEnum):
FR = "fr" # French
EL = "el" # Greek
ES = "es" # Spanish
+ PT = "pt" # Portuguese
class InvitationRequest(InternalModel):
diff --git a/src/apps/logs/domain/notification.py b/src/apps/logs/domain/notification.py
index fa369e8139b0..b041e61c0c13 100644
--- a/src/apps/logs/domain/notification.py
+++ b/src/apps/logs/domain/notification.py
@@ -31,6 +31,38 @@ class NotificationLogCreate(_NotificationLogBase, InternalModel):
notification_in_queue: list[dict] | None
scheduled_notifications: list[dict] | None
+ @validator(
+ "notification_descriptions",
+ "notification_in_queue",
+ "scheduled_notifications",
+ pre=True,
+ )
+ def convert_string_to_list(cls, v):
+ """Convert legacy JSON string format to array format for backward compatibility.
+
+ Mobile apps < v1.0.8 send notification fields as JSON strings.
+ This validator ensures compatibility by converting strings to arrays.
+ """
+ if v is None:
+ return None
+
+ if isinstance(v, str):
+ try:
+ parsed = json.loads(v)
+ if isinstance(parsed, list):
+ return parsed
+ elif isinstance(parsed, dict):
+ return [parsed]
+ return None
+ except json.JSONDecodeError:
+ # Handle malformed JSON gracefully
+ return None
+
+ if isinstance(v, list):
+ return v
+
+ return None
+
@root_validator
def validate_json_fields(cls, values):
if not any(
diff --git a/src/apps/mailing/static/templates/blocks/team_info_pt.html b/src/apps/mailing/static/templates/blocks/team_info_pt.html
new file mode 100644
index 000000000000..bcec7b77dd0a
--- /dev/null
+++ b/src/apps/mailing/static/templates/blocks/team_info_pt.html
@@ -0,0 +1,5 @@
+
+ |
+ – A equipe Curious
+ |
+
diff --git a/src/apps/mailing/static/templates/footers/footer_info_pt.html b/src/apps/mailing/static/templates/footers/footer_info_pt.html
new file mode 100644
index 000000000000..2cd84eccbe12
--- /dev/null
+++ b/src/apps/mailing/static/templates/footers/footer_info_pt.html
@@ -0,0 +1,44 @@
+
+
+ |
+
+ |
+
+
+
+
+ |
+
+
+ |
+ O Child Mind Institute é o criador do Curious, mas não é responsável pelo conteúdo criado por terceiros.
+ |
+
+
diff --git a/src/apps/mailing/static/templates/invitation_new_user_pt.html b/src/apps/mailing/static/templates/invitation_new_user_pt.html
new file mode 100644
index 000000000000..c431137daaf6
--- /dev/null
+++ b/src/apps/mailing/static/templates/invitation_new_user_pt.html
@@ -0,0 +1,28 @@
+
+
+
+ {% include 'header.html' %}
+
+
+ | Olá {{ first_name }}, |
+
+
+ |
+ Você foi convidado a se tornar
+ {% if role[0]|lower in ['a', 'e', 'i', 'o', 'u'] %} an {% else %} a {% endif %}
+ {{ role }} de "{{ applet_name }}" no Curious, uma plataforma de coleta de dados sobre saúde mental e desafios de aprendizagem.
+ |
+
+
+ |
+
+ Crie sua conta e comece a usar
+
+ |
+
+ {% include 'blocks/team_info_pt.html' %}
+
+ {% include 'footers/footer_info_pt.html' %}
+
+
+
diff --git a/src/apps/mailing/static/templates/invitation_registered_user_pt.html b/src/apps/mailing/static/templates/invitation_registered_user_pt.html
new file mode 100644
index 000000000000..f240995da20b
--- /dev/null
+++ b/src/apps/mailing/static/templates/invitation_registered_user_pt.html
@@ -0,0 +1,28 @@
+
+
+
+ {% include 'header.html' %}
+
+
+ | Olá {{ first_name }}, |
+
+
+ |
+ Você foi convidado a se tornar
+ {% if role[0]|lower in ['a', 'e', 'i', 'o', 'u'] %} an {% else %} a {% endif %}
+ {{ role }} de "{{ applet_name }}" no Curious.
+ |
+
+
+ |
+
+ Comece a usar
+
+ |
+
+ {% include 'blocks/team_info_pt.html' %}
+
+ {% include 'footers/footer_info_pt.html' %}
+
+
+
diff --git a/src/apps/mailing/static/templates/new_activity_assignments_pt.html b/src/apps/mailing/static/templates/new_activity_assignments_pt.html
new file mode 100644
index 000000000000..c726fc00d401
--- /dev/null
+++ b/src/apps/mailing/static/templates/new_activity_assignments_pt.html
@@ -0,0 +1,36 @@
+
+
+
+ {% include 'header.html' %}
+
+
+ | Olá {{ first_name }}, |
+
+
+ |
+ Você recebeu uma ou mais atividades em "{{ applet_name }}".
+ |
+
+
+
+ {% for name in activity_or_flows_names %}
+ - {{ name }}
+ {% endfor %}
+
+
+
+ | Faça login para revisar os detalhes da tarefa no Curious. |
+
+
+ |
+
+ Comece a usar
+
+ |
+
+ {% include 'blocks/team_info_pt.html' %}
+
+ {% include 'footers/footer_info_pt.html' %}
+
+
+
diff --git a/src/apps/mailing/static/templates/reset_password_pt.html b/src/apps/mailing/static/templates/reset_password_pt.html
new file mode 100644
index 000000000000..ae710a58efbe
--- /dev/null
+++ b/src/apps/mailing/static/templates/reset_password_pt.html
@@ -0,0 +1,31 @@
+
+
+ {% include 'header.html' %}
+
+
+ |
+ Recebemos uma solicitação para redefinir sua senha.
+ |
+
+
+ |
+ Clique neste link para redefinir sua senha agora. O link irá expirar em 15 minutos.
+ |
+
+
+ |
+ Se você não fez essa solicitação, você pode ignorar este e-mail.
+ |
+
+
+ |
+
+ Após atualizar sua senha, você não verá seus dados anteriores no aplicativo. Mas não se preocupe, nós ainda temos acesso a eles.
+
+ |
+
+ {% include 'blocks/team_info_pt.html' %}
+
+ {% include 'footers/footer_info_pt.html' %}
+
+
diff --git a/src/cli.py b/src/cli.py
index 37433d773075..c2ed878a4eea 100755
--- a/src/cli.py
+++ b/src/cli.py
@@ -7,7 +7,9 @@
import typer # noqa: E402,I001
-from apps.activities.commands import activities # noqa: E402
+from apps.activities.commands.reindex_items import app as reindex_items_cli # noqa: E402
+from apps.activities.commands.delete_subscales import app as delete_subscales_cli # noqa: E402
+
from apps.answers.commands import convert_assessments # noqa: E402
from apps.applets.commands import ( # noqa: E402
applet_cli, # noqa: E402
@@ -21,7 +23,8 @@
cli = typer.Typer()
cli.add_typer(arbitrary_server_cli, name="arbitrary")
cli.add_typer(convert_assessments, name="assessments")
-cli.add_typer(activities, name="activities")
+cli.add_typer(reindex_items_cli, name="reindex")
+cli.add_typer(delete_subscales_cli, name="delete-subscales")
cli.add_typer(token_cli, name="token")
cli.add_typer(patch, name="patch")
cli.add_typer(encryption_cli, name="encryption")
diff --git a/src/infrastructure/database/migrations/versions/2025_05_21_10_55-clean_up_applet_events_table.py b/src/infrastructure/database/migrations/versions/2025_05_21_10_55-clean_up_applet_events_table.py
new file mode 100644
index 000000000000..d8c25480648f
--- /dev/null
+++ b/src/infrastructure/database/migrations/versions/2025_05_21_10_55-clean_up_applet_events_table.py
@@ -0,0 +1,109 @@
+"""Clean up applet_events table
+
+Revision ID: b35481672766
+Revises: a05c838b8aad
+Create Date: 2025-05-21 10:55:15.571446
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "b35481672766"
+down_revision = "a05c838b8aad" # This revision ID does not exist on the develop branch yet, so this PR should not be merged until it does
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create a temporary table to remove the deleted records
+ # Should be removed in a subsequent migration
+ op.create_table(
+ "applet_events_cleanup",
+ sa.Column("is_deleted", sa.Boolean(), nullable=True),
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
+ sa.Column("migrated_date", sa.DateTime(), nullable=True),
+ sa.Column("migrated_updated", sa.DateTime(), nullable=True),
+ sa.Column("applet_id", sa.String(), nullable=False),
+ sa.Column("event_id", sa.String(), nullable=False),
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_applet_events_cleanup")),
+ sa.UniqueConstraint("applet_id", "event_id", "is_deleted", name="_unique_applet_events_cleanup"),
+ )
+
+ # Archive the rows that will be deleted
+ op.execute("""
+ INSERT INTO applet_events_cleanup (is_deleted, id, created_at, updated_at,
+ migrated_date,
+ migrated_updated,
+ applet_id,
+ event_id)
+ SELECT applet_events.is_deleted,
+ applet_events.id,
+ applet_events.created_at,
+ applet_events.updated_at,
+ applet_events.migrated_date,
+ applet_events.migrated_updated,
+ applet_events.applet_id,
+ applet_events.event_id
+ FROM event_histories
+ JOIN applet_events ON event_histories.id_version = applet_events.event_id
+ JOIN applet_histories ON applet_events.applet_id = applet_histories.id_version
+ LEFT OUTER JOIN subjects
+ ON event_histories.user_id = subjects.user_id AND
+ applet_histories.id = subjects.applet_id
+ LEFT OUTER JOIN activity_histories
+ ON event_histories.activity_id = activity_histories.id AND
+ applet_histories.id_version = activity_histories.applet_id
+ LEFT OUTER JOIN flow_histories
+ ON event_histories.activity_flow_id = flow_histories.id AND
+ applet_histories.id_version = flow_histories.applet_id
+ WHERE coalesce(flow_histories.applet_id, activity_histories.applet_id) IS NULL
+ """)
+
+ # Delete entries from applet_events
+ op.execute("""
+ DELETE FROM applet_events
+ WHERE id IN (
+ SELECT applet_events.id
+ FROM event_histories
+ JOIN applet_events ON event_histories.id_version = applet_events.event_id
+ JOIN applet_histories ON applet_events.applet_id = applet_histories.id_version
+ LEFT OUTER JOIN subjects
+ ON event_histories.user_id = subjects.user_id AND
+ applet_histories.id = subjects.applet_id
+ LEFT OUTER JOIN activity_histories
+ ON event_histories.activity_id = activity_histories.id AND
+ applet_histories.id_version = activity_histories.applet_id
+ LEFT OUTER JOIN flow_histories
+ ON event_histories.activity_flow_id = flow_histories.id AND
+ applet_histories.id_version = flow_histories.applet_id
+ WHERE coalesce(flow_histories.applet_id, activity_histories.applet_id) IS NULL
+ )
+ """)
+
+
+def downgrade() -> None:
+ # Restore the archived rows
+ op.execute("""
+ INSERT INTO applet_events (is_deleted, id, created_at, updated_at,
+ migrated_date,
+ migrated_updated,
+ applet_id,
+ event_id)
+ SELECT is_deleted,
+ id,
+ created_at,
+ updated_at,
+ migrated_date,
+ migrated_updated,
+ applet_id,
+ event_id
+ FROM applet_events_cleanup
+ """)
+
+ # Drop the archive table
+ op.drop_table('applet_events_cleanup')
diff --git a/src/infrastructure/database/migrations/versions/2025_08_28_13_10-merge_multiple_heads.py b/src/infrastructure/database/migrations/versions/2025_08_28_13_10-merge_multiple_heads.py
new file mode 100644
index 000000000000..8e697ce84b78
--- /dev/null
+++ b/src/infrastructure/database/migrations/versions/2025_08_28_13_10-merge_multiple_heads.py
@@ -0,0 +1,23 @@
+"""Merge multiple heads
+
+Revision ID: e7fc7c11c8a3
+Revises: 8ec71571fa71, b35481672766
+Create Date: 2025-08-28 13:10:44.003205
+
+"""
+
+
+
+# revision identifiers, used by Alembic.
+revision = "e7fc7c11c8a3"
+down_revision = ("8ec71571fa71", "b35481672766")
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass