diff --git a/echo/directus/sync/collections/operations.json b/echo/directus/sync/collections/operations.json index c169726e..df2d31c5 100644 --- a/echo/directus/sync/collections/operations.json +++ b/echo/directus/sync/collections/operations.json @@ -79,7 +79,7 @@ "resolve": null, "reject": null, "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "615a54cd-a72e-41ad-9403-9577c80280d6" + "_syncId": "84c38ea6-5d15-429f-8c24-9485d54ba7be" }, { "name": "Email Send Operation Failed Dutch", @@ -93,7 +93,7 @@ "resolve": null, "reject": null, "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "84c38ea6-5d15-429f-8c24-9485d54ba7be" + "_syncId": "615a54cd-a72e-41ad-9403-9577c80280d6" }, { "name": "failed", @@ -107,7 +107,7 @@ "resolve": null, "reject": null, "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "8d8d787a-dbc4-44f9-9ab4-28e3f3d5f31c" + "_syncId": "eb6f8253-647f-4fb1-9010-e93594ba065e" }, { "name": "failed", @@ -121,7 +121,7 @@ "resolve": null, "reject": null, "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "eb6f8253-647f-4fb1-9010-e93594ba065e" + "_syncId": "8d8d787a-dbc4-44f9-9ab4-28e3f3d5f31c" }, { "name": "Filter Emails", @@ -132,10 +132,10 @@ "options": { "code": "module.exports = async function(data) {\n\n const submissions = data.get_all_participants;\n \n // Filter submissions to only include those where email_opt_in is true\n const filteredSubmissions = submissions.filter(sub => sub.email_opt_in === true);\n\n // Create an array with email, project_id and an email_opt_out token for each submission\n const result = filteredSubmissions.map(sub => ({\n project_name: data.project_data[0].name || '',\n\t\tdefault_conversation_title: data.project_data[0].default_conversation_title || '',\n\t\tconversation_name: sub.conversation_id.participant_name || '',\n email: sub.email,\n project_id: sub.project_id || '',\n token: sub.email_opt_out_token,\n language: data.check_report_language[0].language || 'empty',\n ADMIN_BASE_URL: \"{{ $env.ADMIN_BASE_URL }}\" || \"http://localhost:5173\",\n PARTICIPANT_BASE_URL: \"{{ $env.PARTICIPANT_BASE_URL }}\" || \"http://localhost:5174\", \n }));\n \n return result;\n};" }, - "resolve": "b8144cee-59f6-40d9-a849-dd0c639e4e31", + "resolve": "e101f00d-2fb8-4f40-9e0e-4d24da5bb1e9", "reject": null, "flow": "ec4e7ea5-72de-4365-b66f-d8f11b549495", - "_syncId": "ca1ffbc5-cfce-4fb4-8f15-c128ea407d41" + "_syncId": "efb3982e-5703-4c07-8982-a6e1b5218e4a" }, { "name": "Filter Emails", @@ -146,10 +146,10 @@ "options": { "code": "module.exports = async function(data) {\n\n const submissions = data.get_all_participants;\n \n // Filter submissions to only include those where email_opt_in is true\n const filteredSubmissions = submissions.filter(sub => sub.email_opt_in === true);\n\n // Create an array with email, project_id and an email_opt_out token for each submission\n const result = filteredSubmissions.map(sub => ({\n project_name: data.project_data[0].name || '',\n\t\tdefault_conversation_title: data.project_data[0].default_conversation_title || '',\n\t\tconversation_name: sub.conversation_id.participant_name || '',\n email: sub.email,\n project_id: sub.project_id || '',\n token: sub.email_opt_out_token,\n language: data.check_report_language[0].language || 'empty',\n ADMIN_BASE_URL: \"{{ $env.ADMIN_BASE_URL }}\" || \"http://localhost:5173\",\n PARTICIPANT_BASE_URL: \"{{ $env.PARTICIPANT_BASE_URL }}\" || \"http://localhost:5174\", \n }));\n \n return result;\n};" }, - "resolve": "e101f00d-2fb8-4f40-9e0e-4d24da5bb1e9", + "resolve": "b8144cee-59f6-40d9-a849-dd0c639e4e31", "reject": null, "flow": "ec4e7ea5-72de-4365-b66f-d8f11b549495", - "_syncId": "efb3982e-5703-4c07-8982-a6e1b5218e4a" + "_syncId": "ca1ffbc5-cfce-4fb4-8f15-c128ea407d41" }, { "name": "log environment vars", @@ -213,7 +213,7 @@ "resolve": null, "reject": null, "flow": "ec4e7ea5-72de-4365-b66f-d8f11b549495", - "_syncId": "84852456-3f3a-4906-be94-8b750159883b" + "_syncId": "e8274ad4-5844-42cd-8a6b-d40d08cf83d3" }, { "name": "Report Not Published", @@ -227,7 +227,7 @@ "resolve": null, "reject": null, "flow": "ec4e7ea5-72de-4365-b66f-d8f11b549495", - "_syncId": "e8274ad4-5844-42cd-8a6b-d40d08cf83d3" + "_syncId": "84852456-3f3a-4906-be94-8b750159883b" }, { "name": "Send Email Dutch", @@ -256,9 +256,9 @@ ] }, "resolve": null, - "reject": "84c38ea6-5d15-429f-8c24-9485d54ba7be", + "reject": "615a54cd-a72e-41ad-9403-9577c80280d6", "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "34fb6ee5-2813-484a-a1cc-f97de097509b" + "_syncId": "ea78ec02-364d-4f18-80f8-ea5ac4c787ed" }, { "name": "Send Email Dutch", @@ -287,9 +287,9 @@ ] }, "resolve": null, - "reject": "615a54cd-a72e-41ad-9403-9577c80280d6", + "reject": "84c38ea6-5d15-429f-8c24-9485d54ba7be", "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "ea78ec02-364d-4f18-80f8-ea5ac4c787ed" + "_syncId": "34fb6ee5-2813-484a-a1cc-f97de097509b" }, { "name": "Send Email English", @@ -318,9 +318,9 @@ ] }, "resolve": null, - "reject": "2b24450b-6a2e-4452-aba1-9814d17fef42", + "reject": "920bd181-b2a2-4f0d-94dc-3b1a08c3f4ef", "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "9390ed2f-7dc6-4a6a-83da-2d87d478261d" + "_syncId": "3dbf2ea1-17f8-4bde-aa89-43278fe9a00f" }, { "name": "Send Email English", @@ -349,9 +349,9 @@ ] }, "resolve": null, - "reject": "920bd181-b2a2-4f0d-94dc-3b1a08c3f4ef", + "reject": "2b24450b-6a2e-4452-aba1-9814d17fef42", "flow": "17703446-fef0-49e9-bdc4-385db1311137", - "_syncId": "3dbf2ea1-17f8-4bde-aa89-43278fe9a00f" + "_syncId": "9390ed2f-7dc6-4a6a-83da-2d87d478261d" }, { "name": "Trigger Email Flow", diff --git a/echo/directus/sync/collections/policies.json b/echo/directus/sync/collections/policies.json index 1dff0924..5687fce3 100644 --- a/echo/directus/sync/collections/policies.json +++ b/echo/directus/sync/collections/policies.json @@ -11,10 +11,6 @@ { "role": "_sync_default_admin_role", "sort": 1 - }, - { - "role": null, - "sort": null } ], "_syncId": "_sync_default_admin_policy" diff --git a/echo/directus/sync/snapshot/fields/conversation/conversation_artifacts.json b/echo/directus/sync/snapshot/fields/conversation/conversation_artifacts.json index 5104cc09..044057bb 100644 --- a/echo/directus/sync/snapshot/fields/conversation/conversation_artifacts.json +++ b/echo/directus/sync/snapshot/fields/conversation/conversation_artifacts.json @@ -16,7 +16,7 @@ "readonly": false, "required": false, "searchable": true, - "sort": 26, + "sort": 27, "special": [ "o2m" ], diff --git a/echo/directus/sync/snapshot/fields/conversation/is_all_chunks_transcribed.json b/echo/directus/sync/snapshot/fields/conversation/is_all_chunks_transcribed.json index dadd0a1e..ca2034d0 100644 --- a/echo/directus/sync/snapshot/fields/conversation/is_all_chunks_transcribed.json +++ b/echo/directus/sync/snapshot/fields/conversation/is_all_chunks_transcribed.json @@ -16,7 +16,7 @@ "readonly": false, "required": false, "searchable": true, - "sort": 23, + "sort": 24, "special": [ "cast-boolean" ], diff --git a/echo/directus/sync/snapshot/fields/conversation/is_anonymized.json b/echo/directus/sync/snapshot/fields/conversation/is_anonymized.json new file mode 100644 index 00000000..08dbba3f --- /dev/null +++ b/echo/directus/sync/snapshot/fields/conversation/is_anonymized.json @@ -0,0 +1,46 @@ +{ + "collection": "conversation", + "field": "is_anonymized", + "type": "boolean", + "meta": { + "collection": "conversation", + "conditions": null, + "display": null, + "display_options": null, + "field": "is_anonymized", + "group": null, + "hidden": false, + "interface": "boolean", + "note": null, + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 21, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "is_anonymized", + "table": "conversation", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/conversation/is_audio_processing_finished.json b/echo/directus/sync/snapshot/fields/conversation/is_audio_processing_finished.json index b4666d93..cc38c92a 100644 --- a/echo/directus/sync/snapshot/fields/conversation/is_audio_processing_finished.json +++ b/echo/directus/sync/snapshot/fields/conversation/is_audio_processing_finished.json @@ -16,7 +16,7 @@ "readonly": false, "required": false, "searchable": true, - "sort": 22, + "sort": 23, "special": [ "cast-boolean" ], diff --git a/echo/directus/sync/snapshot/fields/conversation/is_finished.json b/echo/directus/sync/snapshot/fields/conversation/is_finished.json index fe093e1a..39e45ab0 100644 --- a/echo/directus/sync/snapshot/fields/conversation/is_finished.json +++ b/echo/directus/sync/snapshot/fields/conversation/is_finished.json @@ -16,7 +16,7 @@ "readonly": false, "required": false, "searchable": true, - "sort": 21, + "sort": 22, "special": [ "cast-boolean" ], diff --git a/echo/directus/sync/snapshot/fields/conversation/linked_conversations.json b/echo/directus/sync/snapshot/fields/conversation/linked_conversations.json index af48f54d..d021d4e9 100644 --- a/echo/directus/sync/snapshot/fields/conversation/linked_conversations.json +++ b/echo/directus/sync/snapshot/fields/conversation/linked_conversations.json @@ -21,7 +21,7 @@ "readonly": false, "required": false, "searchable": true, - "sort": 24, + "sort": 25, "special": [ "o2m" ], diff --git a/echo/directus/sync/snapshot/fields/conversation/linking_conversations.json b/echo/directus/sync/snapshot/fields/conversation/linking_conversations.json index 6a8cd255..8df3c1a4 100644 --- a/echo/directus/sync/snapshot/fields/conversation/linking_conversations.json +++ b/echo/directus/sync/snapshot/fields/conversation/linking_conversations.json @@ -20,7 +20,7 @@ "readonly": false, "required": false, "searchable": true, - "sort": 25, + "sort": 26, "special": [ "o2m" ], diff --git a/echo/frontend/src/components/conversation/ConversationAccordion.tsx b/echo/frontend/src/components/conversation/ConversationAccordion.tsx index c714bf70..7b056e12 100644 --- a/echo/frontend/src/components/conversation/ConversationAccordion.tsx +++ b/echo/frontend/src/components/conversation/ConversationAccordion.tsx @@ -34,6 +34,7 @@ import { useMediaQuery, useSessionStorage, } from "@mantine/hooks"; +import { ShieldCheckIcon } from "@phosphor-icons/react"; import { IconArrowsExchange, IconArrowsUpDown, @@ -556,11 +557,13 @@ const ConversationAccordionItem = ({ {conversation.participant_name || conversation.title} + {conversation.title && conversation.participant_name && ( )} + {hasVerifiedArtefacts && ( )} + + {conversation.is_anonymized && ( + + + + + + )} + { - try { analytics.trackEvent(events.SELECT_ALL_CLICK); } - catch (error) { console.warn("Analytics tracking failed:", error); } + try { + analytics.trackEvent(events.SELECT_ALL_CLICK); + } catch (error) { + console.warn("Analytics tracking failed:", error); + } setSelectAllModalOpened(true); setSelectAllResult(null); }; @@ -927,8 +948,11 @@ export const ConversationAccordion = ({ return; } - try { analytics.trackEvent(events.SELECT_ALL_CONFIRM); } - catch (error) { console.warn("Analytics tracking failed:", error); } + try { + analytics.trackEvent(events.SELECT_ALL_CONFIRM); + } catch (error) { + console.warn("Analytics tracking failed:", error); + } setSelectAllLoading(true); try { @@ -940,11 +964,17 @@ export const ConversationAccordion = ({ verifiedOnly: showOnlyVerified || undefined, }); setSelectAllResult(result); - try { analytics.trackEvent(events.SELECT_ALL_SUCCESS); } - catch (error) { console.warn("Analytics tracking failed:", error); } + try { + analytics.trackEvent(events.SELECT_ALL_SUCCESS); + } catch (error) { + console.warn("Analytics tracking failed:", error); + } } catch (_error) { - try { analytics.trackEvent(events.SELECT_ALL_ERROR); } - catch (error) { console.warn("Analytics tracking failed:", error); } + try { + analytics.trackEvent(events.SELECT_ALL_ERROR); + } catch (error) { + console.warn("Analytics tracking failed:", error); + } toast.error(t`Failed to add conversations to context`); setSelectAllModalOpened(false); } finally { diff --git a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx index 486e099c..a1b642ec 100644 --- a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx +++ b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Button, Group, Stack } from "@mantine/core"; +import { Button, Group, Stack, Tooltip } from "@mantine/core"; import { IconDownload, IconTrash } from "@tabler/icons-react"; import { useParams } from "react-router"; import { MoveConversationButton } from "@/components/conversation/MoveConversationButton"; @@ -13,8 +13,10 @@ import { useDeleteConversationByIdMutation } from "./hooks"; export const ConversationDangerZone = ({ conversation, + disableDownloadAudio = false, }: { conversation: Conversation; + disableDownloadAudio?: boolean; }) => { const deleteConversationByIdMutation = useDeleteConversationByIdMutation(); const navigate = useI18nNavigate(); @@ -51,19 +53,29 @@ export const ConversationDangerZone = ({ - + + + )} - {conversationQuery.data?.summary && + {conversationQuery.data?.summary && conversationQuery.data?.is_finished && } )} @@ -257,7 +263,10 @@ export const ProjectConversationOverviewRoute = () => { ) : null} */} - + )} diff --git a/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx b/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx index 4a94c46c..0f491914 100644 --- a/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx +++ b/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx @@ -8,6 +8,7 @@ import { Stack, Switch, Title, + Tooltip, } from "@mantine/core"; import { IconAlertCircle } from "@tabler/icons-react"; import { useEffect } from "react"; @@ -30,7 +31,7 @@ export const ProjectConversationTranscript = () => { conversationId: conversationId ?? "", loadConversationChunks: false, query: { - fields: ["id", "participant_name", "is_finished"], + fields: ["id", "participant_name", "is_finished", "is_anonymized"], }, }); const { ref: loadMoreRef, inView } = useInView(); @@ -58,6 +59,8 @@ export const ProjectConversationTranscript = () => { const allChunks = (chunksData?.pages ?? []).flatMap((page) => page.chunks); + const isAnonymized = conversationQuery.data?.is_anonymized ?? false; + const hasValidTranscripts = allChunks.some( (chunk) => chunk.transcript && chunk.transcript.trim().length > 0, ); @@ -103,18 +106,26 @@ export const ProjectConversationTranscript = () => { - - setShowAudioPlayer(event.currentTarget.checked) - } - label={t`Show audio player`} - {...testId("transcript-show-audio-player-toggle")} - /> + + + setShowAudioPlayer(event.currentTarget.checked) + } + disabled={isAnonymized} + label={t`Show audio player`} + {...testId("transcript-show-audio-player-toggle")} + /> + @@ -149,7 +160,7 @@ export const ProjectConversationTranscript = () => { timestamp: chunk.timestamp ?? "", transcript: chunk.transcript ?? "", }} - showAudioPlayer={showAudioPlayer} + showAudioPlayer={isAnonymized ? false : showAudioPlayer} /> ); diff --git a/echo/frontend/src/styles/button.module.css b/echo/frontend/src/styles/button.module.css index 8c5d6fa2..ba3b0f94 100644 --- a/echo/frontend/src/styles/button.module.css +++ b/echo/frontend/src/styles/button.module.css @@ -60,19 +60,24 @@ /* Disabled - gray bg, graphite text, interactive borders (Peach on hover, Salmon on click) */ /* Note: Loading buttons are disabled but styled differently (see above) */ -.root:disabled:not([data-loading="true"]) { +/* Note: [data-disabled] is needed for anchor-based buttons (component="a") which lack :disabled */ +.root:disabled:not([data-loading="true"]), +.root[data-disabled]:not([data-loading="true"]) { background-color: var(--mantine-color-gray-3); color: var(--mantine-color-graphite-6); /* Graphite */ opacity: 1; /* Override Mantine's default opacity reduction to keep text readable */ cursor: not-allowed; + pointer-events: all; /* Allow hover/active on disabled anchor buttons */ } -.root:disabled:not([data-loading="true"]):hover { +.root:disabled:not([data-loading="true"]):hover, +.root[data-disabled]:not([data-loading="true"]):hover { border: 1px solid var(--mantine-color-peach-6); /* Peach border on hover */ background-color: var(--mantine-color-gray-3); /* Keep gray background */ } -.root:disabled:not([data-loading="true"]):active { +.root:disabled:not([data-loading="true"]):active, +.root[data-disabled]:not([data-loading="true"]):active { border: 1px solid var(--mantine-color-salmon-6); /* Salmon border on click */ background-color: var(--mantine-color-gray-3); /* Keep gray background */ } diff --git a/echo/server/dembrane/api/conversation.py b/echo/server/dembrane/api/conversation.py index 9838a41a..171804fc 100644 --- a/echo/server/dembrane/api/conversation.py +++ b/echo/server/dembrane/api/conversation.py @@ -669,7 +669,9 @@ async def generate_title_for_conversation( summary = conversation_data.get("summary") if not summary: - raise HTTPException(status_code=400, detail="Conversation has no summary. Generate a summary first.") + raise HTTPException( + status_code=400, detail="Conversation has no summary. Generate a summary first." + ) project_data = conversation_data["project_id"] language = project_data.get("language", "en") @@ -766,7 +768,12 @@ async def retranscribe_conversation( project_data = await run_in_thread_pool( admin_client.get_items, "project", - {"query": {"filter": {"id": {"_eq": project_id}}, "fields": ["anonymize_transcripts"]}}, + { + "query": { + "filter": {"id": {"_eq": project_id}}, + "fields": ["anonymize_transcripts"], + } + }, ) if project_data and len(project_data) > 0: use_pii_redaction = bool(project_data[0].get("anonymize_transcripts", False)) @@ -815,6 +822,7 @@ async def retranscribe_conversation( if original_conversation["participant_user_agent"] else None, "merged_audio_path": merged_audio_path, + "is_anonymized": use_pii_redaction, }, ) diff --git a/echo/server/dembrane/service/conversation.py b/echo/server/dembrane/service/conversation.py index 13d94325..75ccf2cd 100644 --- a/echo/server/dembrane/service/conversation.py +++ b/echo/server/dembrane/service/conversation.py @@ -290,6 +290,7 @@ def create( "participant_email": participant_email, "participant_user_agent": participant_user_agent, "source": source, + "is_anonymized": bool(project.get("anonymize_transcripts", False)), "tags": { "create": [ { diff --git a/echo/server/dembrane/tasks.py b/echo/server/dembrane/tasks.py index 1a46b83b..67d63c61 100644 --- a/echo/server/dembrane/tasks.py +++ b/echo/server/dembrane/tasks.py @@ -579,22 +579,10 @@ def task_process_conversation_chunk( conversation_id = chunk["conversation_id"] logger.debug(f"Chunk {chunk_id} found in conversation: {conversation_id}") - # Fetch anonymize_transcripts flag from the project - anonymize_transcripts = False - try: - from dembrane.directus import directus as _directus - conversation_data = _directus.get_items("conversation", { - "query": { - "filter": {"id": {"_eq": conversation_id}}, - "fields": ["project_id.anonymize_transcripts"], - } - }) - if conversation_data and len(conversation_data) > 0: - project_data = conversation_data[0].get("project_id") - if isinstance(project_data, dict): - anonymize_transcripts = bool(project_data.get("anonymize_transcripts", False)) - except Exception as e: - logger.warning(f"Failed to fetch anonymize_transcripts for chunk {chunk_id}: {e}") + # Read is_anonymized from the conversation itself + conversation = conversation_service.get_by_id_or_raise(conversation_id) + anonymize_transcripts = bool(conversation.get("is_anonymized", False)) + logger.debug(f"Conversation {conversation_id} is_anonymized: {anonymize_transcripts}") # critical section with ProcessingStatusContext(