From de75088f90dd1b6fc8f1a5e1921d7341b2fc1a61 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Wed, 5 Mar 2025 11:03:21 +0100 Subject: [PATCH 01/15] Add options button to show menu if exists error --- .../components/sync-summary/SyncSummary.tsx | 195 +++++++++++------- 1 file changed, 120 insertions(+), 75 deletions(-) diff --git a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx index 7075b39c8..fd84725db 100644 --- a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx +++ b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx @@ -5,6 +5,8 @@ import { AccordionSummary, DialogContent, makeStyles, + Menu, + MenuItem, Table, TableBody, TableCell, @@ -15,7 +17,7 @@ import { } from "@material-ui/core"; import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; import _ from "lodash"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { JsonView, Props, defaultStyles } from "react-json-view-lite"; import { SynchronizationReport } from "../../../../../domain/reports/entities/SynchronizationReport"; import { ErrorMessage, SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; @@ -165,111 +167,154 @@ const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { const loading = useLoading(); const [results, setResults] = useState(report.getResults()); + + const [anchorEl, setAnchorEl] = useState(null); + const payloads = _.compact(report.getResults().map(({ payload }) => payload)); + const errorStatus = useMemo( + () => + report + .getResults() + .map(({ status }) => status) + .filter(status => status === "ERROR"), + [report] + ); + const downloadJSON = async () => { loading.show(true, i18n.t("Generating JSON")); await compositionRoot.reports.downloadPayloads([report]); loading.reset(); }; + const handleOpenOptionsMenu = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleCloseOptionsMenu = useCallback(() => { + setAnchorEl(null); + }, []); + + const shareError = useCallback(() => { + setAnchorEl(null); + }, []); + useEffect(() => { if (report.getResults().length > 0) return; compositionRoot.reports.getSyncResults(report.id).then(setResults); }, [compositionRoot, report]); return ( - 0 ? downloadJSON : undefined} - cancelText={i18n.t("Ok")} - maxWidth={"lg"} - fullWidth={true} - infoActionText={i18n.t("Download JSON Payload")} - > - - {results.map( - ({ origin, instance, status, typeStats = [], stats, message, errors, type, originPackage }, i) => ( - - }> - - {`Type: ${getTypeName(type, report.type)}`} -
- {origin && `${i18n.t("Origin")}: ${getOriginName(origin)}`} - {origin &&
} - {originPackage && `${i18n.t("Origin package")}: ${originPackage.name}`} - {originPackage &&
} - {`${i18n.t("Destination instance")}: ${instance.name}`} -
- - {`${i18n.t("Status")}: `} - {formatStatusTag(status)} - -
- - - {i18n.t("Summary")} - - - {message && ( - - {message} - - )} + <> + 0 ? handleOpenOptionsMenu : payloads.length > 0 ? downloadJSON : undefined + } + cancelText={i18n.t("Ok")} + maxWidth={"lg"} + fullWidth={true} + infoActionText={errorStatus.length > 0 ? i18n.t("Options") : i18n.t("Download JSON Payload")} + > + + {results.map( + ( + { origin, instance, status, typeStats = [], stats, message, errors, type, originPackage }, + i + ) => ( + + }> + + {`Type: ${getTypeName(type, report.type)}`} +
+ {origin && `${i18n.t("Origin")}: ${getOriginName(origin)}`} + {origin &&
} + {originPackage && `${i18n.t("Origin package")}: ${originPackage.name}`} + {originPackage &&
} + {`${i18n.t("Destination instance")}: ${instance.name}`} +
+ + {`${i18n.t("Status")}: `} + {formatStatusTag(status)} + +
- {stats && ( - + {i18n.t("Summary")} - )} - {errors && errors.length > 0 && ( -
+ {message && ( - {i18n.t("Messages")} + {message} + )} + + {stats && ( - {buildMessageTable(_.take(errors, 10))} + -
- )} + )} + + {errors && errors.length > 0 && ( +
+ + {i18n.t("Messages")} + + + {buildMessageTable(_.take(errors, 10))} + +
+ )} +
+ ) + )} + + {report.dataStats && ( + + }> + + {i18n.t("Data Statistics")} + + + + + {buildDataStatsTable(report.type, report.dataStats, classes)} + - ) - )} + )} - {report.dataStats && ( }> - {i18n.t("Data Statistics")} + {i18n.t("JSON Response")} - {buildDataStatsTable(report.type, report.dataStats, classes)} + - )} - - - }> - {i18n.t("JSON Response")} - - - - - - -
-
+
+
+ + {i18n.t("Download JSON Payload")} + {i18n.t("Share error information")} + + ); }; From f6b19f6ec361d1183fa4cc996cbea8b4c8f7b898 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Wed, 5 Mar 2025 11:46:44 +0100 Subject: [PATCH 02/15] Create basic UI to send email --- i18n/en.pot | 13 ++- i18n/es.po | 11 ++- i18n/fr.po | 11 ++- i18n/pt.po | 11 ++- .../share-sync-error/ShareSyncError.tsx | 85 +++++++++++++++++++ .../share-sync-error/useShareSyncError.ts | 37 ++++++++ .../components/sync-summary/SyncSummary.tsx | 22 ++--- 7 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx create mode 100644 src/presentation/react/core/components/share-sync-error/useShareSyncError.ts diff --git a/i18n/en.pot b/i18n/en.pot index aa7c6e485..7c01dd2d4 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-03-04T08:39:23.402Z\n" -"PO-Revision-Date: 2025-03-04T08:39:23.402Z\n" +"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" +"PO-Revision-Date: 2025-03-05T10:13:18.576Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -1000,6 +1000,15 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + msgid "The token is empty" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index a03f55515..a26798919 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-02-14T06:29:56.875Z\n" +"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1002,6 +1002,15 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + msgid "The token is empty" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index df35475f7..9a83a87dd 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-02-14T06:29:56.875Z\n" +"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1001,6 +1001,15 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + msgid "The token is empty" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index df35475f7..9a83a87dd 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-02-14T06:29:56.875Z\n" +"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1001,6 +1001,15 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + msgid "The token is empty" msgstr "" diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx new file mode 100644 index 000000000..d6a3c8dd2 --- /dev/null +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -0,0 +1,85 @@ +import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; +import { DialogContent, TextField } from "@material-ui/core"; +import { SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; +import i18n from "../../../../../locales"; +import React from "react"; +import styled from "styled-components"; +import { useShareSyncError } from "./useShareSyncError"; + +interface SyncSummaryProps { + errorResults: SynchronizationResult[]; + onClose: () => void; +} + +export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { + const state = useShareSyncError(); + + const handleToChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + state.onToChange(value); + }; + + const handleSubjectChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + state.onSubjectChange(value); + }; + + const handleMessageChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + state.onMessageChange(value); + }; + + return ( + + + + + + + + + + ); +}; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const StyledTextField = styled(TextField)` + width: 100%; +`; diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts new file mode 100644 index 000000000..e39cac1c1 --- /dev/null +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from "react"; + +type ShareSyncState = { + to: string; + subject: string; + message: string; + onToChange: (to: string) => void; + onSubjectChange: (subject: string) => void; + onMessageChange: (message: string) => void; +}; + +export function useShareSyncError(): ShareSyncState { + const [to, setTo] = useState(""); + const [subject, setSubject] = useState(""); + const [message, setMessage] = useState(""); + + const onToChange = useCallback((to: string) => { + setTo(to); + }, []); + + const onSubjectChange = useCallback((subject: string) => { + setSubject(subject); + }, []); + + const onMessageChange = useCallback((message: string) => { + setMessage(message); + }, []); + + return { + to, + subject, + message, + onToChange, + onSubjectChange, + onMessageChange, + }; +} diff --git a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx index fd84725db..8d5d173be 100644 --- a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx +++ b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx @@ -32,6 +32,7 @@ import { NamedRef } from "../../../../../domain/common/entities/Ref"; import { SummaryTable } from "./SummaryTable"; import "react-json-view-lite/dist/index.css"; +import { ShareSyncError } from "../share-sync-error/ShareSyncError"; const useStyles = makeStyles(theme => ({ accordionHeading1: { @@ -170,16 +171,11 @@ const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { const [anchorEl, setAnchorEl] = useState(null); + const [shareErrorOpen, setShareErrorOpen] = useState(false); + const payloads = _.compact(report.getResults().map(({ payload }) => payload)); - const errorStatus = useMemo( - () => - report - .getResults() - .map(({ status }) => status) - .filter(status => status === "ERROR"), - [report] - ); + const errorResults = useMemo(() => report.getResults().filter(result => result.status === "ERROR"), [report]); const downloadJSON = async () => { loading.show(true, i18n.t("Generating JSON")); @@ -195,7 +191,12 @@ const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { setAnchorEl(null); }, []); + const handleCloseShareError = useCallback(() => { + setShareErrorOpen(false); + }, []); + const shareError = useCallback(() => { + setShareErrorOpen(true); setAnchorEl(null); }, []); @@ -211,12 +212,12 @@ const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { title={i18n.t("Synchronization Results")} onCancel={onClose} onInfoAction={ - errorStatus.length > 0 ? handleOpenOptionsMenu : payloads.length > 0 ? downloadJSON : undefined + errorResults.length > 0 ? handleOpenOptionsMenu : payloads.length > 0 ? downloadJSON : undefined } cancelText={i18n.t("Ok")} maxWidth={"lg"} fullWidth={true} - infoActionText={errorStatus.length > 0 ? i18n.t("Options") : i18n.t("Download JSON Payload")} + infoActionText={errorResults.length > 0 ? i18n.t("Options") : i18n.t("Download JSON Payload")} > {results.map( @@ -314,6 +315,7 @@ const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { {i18n.t("Download JSON Payload")} {i18n.t("Share error information")} + {shareErrorOpen && } ); }; From 6eb2ae27ddeeef29eef3cf0fa19de7671f71ba28 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Wed, 5 Mar 2025 19:36:21 +0100 Subject: [PATCH 03/15] Create infrastructure to send email --- src/data/email/EmailD2ApiRepository.ts | 34 ++++++++++++ src/domain/email/Email.ts | 9 +++ src/domain/email/EmailRepository.ts | 7 +++ src/domain/email/SendEmailError.ts | 7 +++ src/domain/email/SendEmailUseCase.ts | 12 ++++ src/presentation/CompositionRoot.ts | 15 ++++- .../share-sync-error/ShareSyncError.tsx | 28 ++++++++-- .../share-sync-error/useShareSyncError.ts | 55 ++++++++++++++++--- 8 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 src/data/email/EmailD2ApiRepository.ts create mode 100644 src/domain/email/Email.ts create mode 100644 src/domain/email/EmailRepository.ts create mode 100644 src/domain/email/SendEmailError.ts create mode 100644 src/domain/email/SendEmailUseCase.ts diff --git a/src/data/email/EmailD2ApiRepository.ts b/src/data/email/EmailD2ApiRepository.ts new file mode 100644 index 000000000..2f1ccb915 --- /dev/null +++ b/src/data/email/EmailD2ApiRepository.ts @@ -0,0 +1,34 @@ +import i18n from "@eyeseetea/feedback-component/locales"; + +import { Email } from "../../domain/email/Email"; +import { EmailRepository } from "../../domain/email/EmailRepository"; +import { SendEmailError } from "../../domain/email/SendEmailError"; +import { D2Api } from "../../types/d2-api"; +import { Either } from "../../domain/common/entities/Either"; +import { err } from "cmd-ts/dist/cjs/Result"; + +export class EmailD2ApiRepository implements EmailRepository { + constructor(private readonly d2Api: D2Api) {} + + async send(message: Email): Promise> { + try { + await this.d2Api.email.sendMessage(message).getData(); + + return Either.success(undefined); + } catch (error) { + const err = error as any; + + if (err.response?.data) { + const messageError = err.response.data.message; + + return Either.error(new SendEmailError(messageError)); + } else if (error instanceof Error) { + const messageError = error.message ?? i18n.t("Unknown error to send email"); + + return Either.error(new SendEmailError(messageError)); + } else { + return Either.error(new SendEmailError(i18n.t("Unknown error to send email"))); + } + } + } +} diff --git a/src/domain/email/Email.ts b/src/domain/email/Email.ts new file mode 100644 index 000000000..21e9c267f --- /dev/null +++ b/src/domain/email/Email.ts @@ -0,0 +1,9 @@ +import { Struct } from "../common/entities/Struct"; + +export type EmailProps = { + recipients: string[]; + subject: string; + text: string; +}; + +export class Email extends Struct() {} diff --git a/src/domain/email/EmailRepository.ts b/src/domain/email/EmailRepository.ts new file mode 100644 index 000000000..5a416d3ab --- /dev/null +++ b/src/domain/email/EmailRepository.ts @@ -0,0 +1,7 @@ +import { Either } from "../common/entities/Either"; +import { Email } from "./Email"; +import { SendEmailError } from "./SendEmailError"; + +export interface EmailRepository { + send(message: Email): Promise>; +} diff --git a/src/domain/email/SendEmailError.ts b/src/domain/email/SendEmailError.ts new file mode 100644 index 000000000..1274762a2 --- /dev/null +++ b/src/domain/email/SendEmailError.ts @@ -0,0 +1,7 @@ +export class SendEmailError extends Error { + constructor(message: string) { + super(message); + this.name = "SendEmailError"; + Object.setPrototypeOf(this, SendEmailError.prototype); + } +} diff --git a/src/domain/email/SendEmailUseCase.ts b/src/domain/email/SendEmailUseCase.ts new file mode 100644 index 000000000..0bb190e63 --- /dev/null +++ b/src/domain/email/SendEmailUseCase.ts @@ -0,0 +1,12 @@ +import { Either } from "../common/entities/Either"; +import { Email } from "./Email"; +import { EmailRepository } from "./EmailRepository"; +import { SendEmailError } from "./SendEmailError"; + +export class SendEmailUseCase { + constructor(private emailRepository: EmailRepository) {} + + execute(email: Email): Promise> { + return this.emailRepository.send(email); + } +} diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index 0572a8653..22417789b 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -128,9 +128,13 @@ import { TableColumnsDataStoreRepository } from "../data/table-columns/TableColu import { getD2APiFromInstance } from "../utils/d2-utils"; import { RoleD2ApiRepository } from "../data/role/RoleD2ApiRepository"; import { ValidateRolesUseCase } from "../domain/role/ValidateRolesUseCase"; +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { SendEmailUseCase } from "../domain/email/SendEmailUseCase"; +import { EmailD2ApiRepository } from "../data/email/EmailD2ApiRepository"; export class CompositionRoot { private repositoryFactory: RepositoryFactory; + private api: D2Api; constructor(public readonly localInstance: Instance, encryptionKey: string) { this.repositoryFactory = new RepositoryFactory(encryptionKey); @@ -160,6 +164,8 @@ export class CompositionRoot { this.repositoryFactory.bind(Repositories.DataStoreMetadataRepository, DataStoreMetadataD2Repository); this.repositoryFactory.bind(Repositories.DhisReleasesRepository, DhisReleasesLocalRepository); this.repositoryFactory.bind(Repositories.TableColumnsRepository, TableColumnsDataStoreRepository); + + this.api = getD2APiFromInstance(this.localInstance); } @cache() @@ -427,10 +433,15 @@ export class CompositionRoot { @cache() public get roles() { - const api = getD2APiFromInstance(this.localInstance); + return getExecute({ + validate: new ValidateRolesUseCase(new RoleD2ApiRepository(this.api)), + }); + } + @cache() + public get email() { return getExecute({ - validate: new ValidateRolesUseCase(new RoleD2ApiRepository(api)), + send: new SendEmailUseCase(new EmailD2ApiRepository(this.api)), }); } diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx index d6a3c8dd2..9d9730588 100644 --- a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -1,8 +1,8 @@ -import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; +import { ConfirmationDialog, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import { DialogContent, TextField } from "@material-ui/core"; import { SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; import i18n from "../../../../../locales"; -import React from "react"; +import React, { useEffect } from "react"; import styled from "styled-components"; import { useShareSyncError } from "./useShareSyncError"; @@ -13,6 +13,24 @@ interface SyncSummaryProps { export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { const state = useShareSyncError(); + const snackbar = useSnackbar(); + const loading = useLoading(); + + useEffect(() => { + if (state.messageToUser?.type === "success") { + snackbar.success(state.messageToUser.message); + } else if (state.messageToUser?.type === "error") { + snackbar.error(state.messageToUser.message); + } + }, [state.messageToUser, snackbar]); + + useEffect(() => { + if (state.sending) { + loading.show(true, i18n.t("Sending email")); + } else { + loading.hide(); + } + }, [state.sending, loading]); const handleToChange = (event: React.ChangeEvent) => { const { value } = event.target; @@ -29,7 +47,7 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { const handleMessageChange = (event: React.ChangeEvent) => { const { value } = event.target; - state.onMessageChange(value); + state.onTextChange(value); }; return ( @@ -37,7 +55,7 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { isOpen={true} title={i18n.t("Share error information")} onCancel={onClose} - onSave={onClose} + onSave={state.onSendEmail} cancelText={i18n.t("Discard")} saveText={i18n.t("Send")} maxWidth={"lg"} @@ -62,7 +80,7 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { void; onSubjectChange: (subject: string) => void; - onMessageChange: (message: string) => void; + onTextChange: (message: string) => void; + onSendEmail: () => void; +}; + +type MessageToUser = { + message: string; + type: "error" | "success"; }; export function useShareSyncError(): ShareSyncState { const [to, setTo] = useState(""); const [subject, setSubject] = useState(""); - const [message, setMessage] = useState(""); + const [text, setText] = useState(""); + const [messageToUser, setMessageToUser] = useState(); + const [sending, setSending] = useState(false); + + const { compositionRoot } = useAppContext(); const onToChange = useCallback((to: string) => { setTo(to); @@ -22,16 +37,42 @@ export function useShareSyncError(): ShareSyncState { setSubject(subject); }, []); - const onMessageChange = useCallback((message: string) => { - setMessage(message); + const onTextChange = useCallback((message: string) => { + setText(message); }, []); + const onSendEmail = useCallback(async () => { + const email = Email.create({ + recipients: to.split(","), + subject, + text, + }); + + setSending(true); + + const response = await compositionRoot.email.send(email); + + response.match({ + error: error => { + setSending(false); + setMessageToUser({ message: error.message, type: "error" }); + }, + success: () => { + setSending(false); + setMessageToUser({ message: i18n.t("Email sending successfully"), type: "success" }); + }, + }); + }, [compositionRoot.email, text, subject, to]); + return { to, subject, - message, + text, + messageToUser, + sending, onToChange, onSubjectChange, - onMessageChange, + onTextChange, + onSendEmail, }; } From 5d588ff382e4b0589cb0a39ad3829ee07885b73c Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 6 Mar 2025 07:14:01 +0100 Subject: [PATCH 04/15] Use future instead of promise --- src/data/common/utils/futures.ts | 6 +++- src/data/email/EmailD2ApiRepository.ts | 29 +++---------------- src/domain/email/EmailRepository.ts | 5 ++-- src/domain/email/SendEmailUseCase.ts | 5 ++-- .../share-sync-error/useShareSyncError.ts | 16 +++++----- 5 files changed, 20 insertions(+), 41 deletions(-) diff --git a/src/data/common/utils/futures.ts b/src/data/common/utils/futures.ts index d82503dd0..b1d1c6fa7 100644 --- a/src/data/common/utils/futures.ts +++ b/src/data/common/utils/futures.ts @@ -1,11 +1,15 @@ import { CancelableResponse } from "@eyeseetea/d2-api/repositories/CancelableResponse"; import { Future, FutureData } from "../../../domain/common/entities/Future"; +import i18n from "../../../locales"; export function apiToFuture(res: CancelableResponse): FutureData { return Future.fromComputation((resolve, reject) => { res.getData() .then(resolve) - .catch(err => reject(err ? err.message : "Unknown error")); + .catch(err => { + reject(err.response?.data?.message || err.message || i18n.t("Unknown error to send email")); + }); + return res.cancel; }); } diff --git a/src/data/email/EmailD2ApiRepository.ts b/src/data/email/EmailD2ApiRepository.ts index 2f1ccb915..15a13c5e8 100644 --- a/src/data/email/EmailD2ApiRepository.ts +++ b/src/data/email/EmailD2ApiRepository.ts @@ -1,34 +1,13 @@ -import i18n from "@eyeseetea/feedback-component/locales"; - import { Email } from "../../domain/email/Email"; import { EmailRepository } from "../../domain/email/EmailRepository"; -import { SendEmailError } from "../../domain/email/SendEmailError"; import { D2Api } from "../../types/d2-api"; -import { Either } from "../../domain/common/entities/Either"; -import { err } from "cmd-ts/dist/cjs/Result"; +import { apiToFuture } from "../common/utils/futures"; +import { FutureData } from "../../domain/common/entities/Future"; export class EmailD2ApiRepository implements EmailRepository { constructor(private readonly d2Api: D2Api) {} - async send(message: Email): Promise> { - try { - await this.d2Api.email.sendMessage(message).getData(); - - return Either.success(undefined); - } catch (error) { - const err = error as any; - - if (err.response?.data) { - const messageError = err.response.data.message; - - return Either.error(new SendEmailError(messageError)); - } else if (error instanceof Error) { - const messageError = error.message ?? i18n.t("Unknown error to send email"); - - return Either.error(new SendEmailError(messageError)); - } else { - return Either.error(new SendEmailError(i18n.t("Unknown error to send email"))); - } - } + send(message: Email): FutureData { + return apiToFuture(this.d2Api.email.sendMessage(message)); } } diff --git a/src/domain/email/EmailRepository.ts b/src/domain/email/EmailRepository.ts index 5a416d3ab..b14859383 100644 --- a/src/domain/email/EmailRepository.ts +++ b/src/domain/email/EmailRepository.ts @@ -1,7 +1,6 @@ -import { Either } from "../common/entities/Either"; +import { FutureData } from "../common/entities/Future"; import { Email } from "./Email"; -import { SendEmailError } from "./SendEmailError"; export interface EmailRepository { - send(message: Email): Promise>; + send(message: Email): FutureData; } diff --git a/src/domain/email/SendEmailUseCase.ts b/src/domain/email/SendEmailUseCase.ts index 0bb190e63..afce76268 100644 --- a/src/domain/email/SendEmailUseCase.ts +++ b/src/domain/email/SendEmailUseCase.ts @@ -1,12 +1,11 @@ -import { Either } from "../common/entities/Either"; +import { FutureData } from "../common/entities/Future"; import { Email } from "./Email"; import { EmailRepository } from "./EmailRepository"; -import { SendEmailError } from "./SendEmailError"; export class SendEmailUseCase { constructor(private emailRepository: EmailRepository) {} - execute(email: Email): Promise> { + execute(email: Email): FutureData { return this.emailRepository.send(email); } } diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index d64ec00e6..3d2247748 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -50,18 +50,16 @@ export function useShareSyncError(): ShareSyncState { setSending(true); - const response = await compositionRoot.email.send(email); - - response.match({ - error: error => { - setSending(false); - setMessageToUser({ message: error.message, type: "error" }); - }, - success: () => { + compositionRoot.email.send(email).run( + () => { setSending(false); setMessageToUser({ message: i18n.t("Email sending successfully"), type: "success" }); }, - }); + error => { + setSending(false); + setMessageToUser({ message: error, type: "error" }); + } + ); }, [compositionRoot.email, text, subject, to]); return { From e0776c63997352b222080b0d7e3596a1f4aeb100 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 6 Mar 2025 10:39:05 +0100 Subject: [PATCH 05/15] Implement attached files for summary and payload --- src/data/email/AttachedFileD2ApiRepository.ts | 29 +++++++ src/data/email/EmailD2ApiRepository.ts | 4 +- src/domain/email/EmailRepository.ts | 6 -- src/domain/email/SendEmailError.ts | 7 -- src/domain/email/entities/AttachedFile.ts | 10 +++ src/domain/email/{ => entities}/Email.ts | 2 +- .../repositories/AttachedFileRepository.ts | 6 ++ .../email/repositories/EmailRepository.ts | 6 ++ .../email/usecases/AttachFileUseCase.ts | 11 +++ .../email/{ => usecases}/SendEmailUseCase.ts | 6 +- .../reports/entities/SynchronizationResult.ts | 9 ++- src/presentation/CompositionRoot.ts | 5 +- .../share-sync-error/ShareSyncError.tsx | 10 ++- .../share-sync-error/useShareSyncError.ts | 80 ++++++++++++++++++- 14 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 src/data/email/AttachedFileD2ApiRepository.ts delete mode 100644 src/domain/email/EmailRepository.ts delete mode 100644 src/domain/email/SendEmailError.ts create mode 100644 src/domain/email/entities/AttachedFile.ts rename src/domain/email/{ => entities}/Email.ts (72%) create mode 100644 src/domain/email/repositories/AttachedFileRepository.ts create mode 100644 src/domain/email/repositories/EmailRepository.ts create mode 100644 src/domain/email/usecases/AttachFileUseCase.ts rename src/domain/email/{ => usecases}/SendEmailUseCase.ts (53%) diff --git a/src/data/email/AttachedFileD2ApiRepository.ts b/src/data/email/AttachedFileD2ApiRepository.ts new file mode 100644 index 000000000..c24eb57e6 --- /dev/null +++ b/src/data/email/AttachedFileD2ApiRepository.ts @@ -0,0 +1,29 @@ +import { Future, FutureData } from "../../domain/common/entities/Future"; +import { AttachedFile, AttachedFileInput } from "../../domain/email/entities/AttachedFile"; +import { AttachedFileRepository } from "../../domain/email/repositories/AttachedFileRepository"; +import { D2Api } from "../../types/d2-api"; +import { apiToFuture } from "../common/utils/futures"; + +export class AttachedFileD2ApiRepository implements AttachedFileRepository { + constructor(private api: D2Api) {} + + save(file: AttachedFileInput): FutureData { + return apiToFuture(this.api.files.upload(file)) + .flatMap(({ id }) => { + return Future.joinObj({ + id: Future.success(id), + sharing: apiToFuture( + this.api.sharing.post( + { id, type: "document" }, + { publicAccess: "rw------", externalAccess: true } + ) + ), + }); + }) + .map(({ id }) => { + const isDev = process.env.NODE_ENV === "development"; + const baseUrl = isDev ? process.env.REACT_APP_DHIS2_BASE_URL : this.api.baseUrl; + return { id, name: file.name, url: `${baseUrl}/api/documents/${id}/data` }; + }); + } +} diff --git a/src/data/email/EmailD2ApiRepository.ts b/src/data/email/EmailD2ApiRepository.ts index 15a13c5e8..c4e2c109f 100644 --- a/src/data/email/EmailD2ApiRepository.ts +++ b/src/data/email/EmailD2ApiRepository.ts @@ -1,5 +1,5 @@ -import { Email } from "../../domain/email/Email"; -import { EmailRepository } from "../../domain/email/EmailRepository"; +import { Email } from "../../domain/email/entities/Email"; +import { EmailRepository } from "../../domain/email/repositories/EmailRepository"; import { D2Api } from "../../types/d2-api"; import { apiToFuture } from "../common/utils/futures"; import { FutureData } from "../../domain/common/entities/Future"; diff --git a/src/domain/email/EmailRepository.ts b/src/domain/email/EmailRepository.ts deleted file mode 100644 index b14859383..000000000 --- a/src/domain/email/EmailRepository.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FutureData } from "../common/entities/Future"; -import { Email } from "./Email"; - -export interface EmailRepository { - send(message: Email): FutureData; -} diff --git a/src/domain/email/SendEmailError.ts b/src/domain/email/SendEmailError.ts deleted file mode 100644 index 1274762a2..000000000 --- a/src/domain/email/SendEmailError.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class SendEmailError extends Error { - constructor(message: string) { - super(message); - this.name = "SendEmailError"; - Object.setPrototypeOf(this, SendEmailError.prototype); - } -} diff --git a/src/domain/email/entities/AttachedFile.ts b/src/domain/email/entities/AttachedFile.ts new file mode 100644 index 000000000..ed35e733b --- /dev/null +++ b/src/domain/email/entities/AttachedFile.ts @@ -0,0 +1,10 @@ +export type AttachedFile = { + id: string; + name: string; + url: string; +}; + +export interface AttachedFileInput { + name: string; + data: Blob; +} diff --git a/src/domain/email/Email.ts b/src/domain/email/entities/Email.ts similarity index 72% rename from src/domain/email/Email.ts rename to src/domain/email/entities/Email.ts index 21e9c267f..56157ea3f 100644 --- a/src/domain/email/Email.ts +++ b/src/domain/email/entities/Email.ts @@ -1,4 +1,4 @@ -import { Struct } from "../common/entities/Struct"; +import { Struct } from "../../common/entities/Struct"; export type EmailProps = { recipients: string[]; diff --git a/src/domain/email/repositories/AttachedFileRepository.ts b/src/domain/email/repositories/AttachedFileRepository.ts new file mode 100644 index 000000000..88387aa4c --- /dev/null +++ b/src/domain/email/repositories/AttachedFileRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../common/entities/Future"; +import { AttachedFile, AttachedFileInput } from "../entities/AttachedFile"; + +export interface AttachedFileRepository { + save(file: AttachedFileInput): FutureData; +} diff --git a/src/domain/email/repositories/EmailRepository.ts b/src/domain/email/repositories/EmailRepository.ts new file mode 100644 index 000000000..8f5be9bac --- /dev/null +++ b/src/domain/email/repositories/EmailRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../common/entities/Future"; +import { Email } from "../entities/Email"; + +export interface EmailRepository { + send(message: Email): FutureData; +} diff --git a/src/domain/email/usecases/AttachFileUseCase.ts b/src/domain/email/usecases/AttachFileUseCase.ts new file mode 100644 index 000000000..765e6b028 --- /dev/null +++ b/src/domain/email/usecases/AttachFileUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../common/entities/Future"; +import { AttachedFile, AttachedFileInput } from "../entities/AttachedFile"; +import { AttachedFileRepository } from "../repositories/AttachedFileRepository"; + +export class AttachFileUseCase { + constructor(private repository: AttachedFileRepository) {} + + execute(file: AttachedFileInput): FutureData { + return this.repository.save(file); + } +} diff --git a/src/domain/email/SendEmailUseCase.ts b/src/domain/email/usecases/SendEmailUseCase.ts similarity index 53% rename from src/domain/email/SendEmailUseCase.ts rename to src/domain/email/usecases/SendEmailUseCase.ts index afce76268..f662d366d 100644 --- a/src/domain/email/SendEmailUseCase.ts +++ b/src/domain/email/usecases/SendEmailUseCase.ts @@ -1,6 +1,6 @@ -import { FutureData } from "../common/entities/Future"; -import { Email } from "./Email"; -import { EmailRepository } from "./EmailRepository"; +import { FutureData } from "../../common/entities/Future"; +import { Email } from "../entities/Email"; +import { EmailRepository } from "../repositories/EmailRepository"; export class SendEmailUseCase { constructor(private emailRepository: EmailRepository) {} diff --git a/src/domain/reports/entities/SynchronizationResult.ts b/src/domain/reports/entities/SynchronizationResult.ts index 9988ae678..6e443aa81 100644 --- a/src/domain/reports/entities/SynchronizationResult.ts +++ b/src/domain/reports/entities/SynchronizationResult.ts @@ -21,10 +21,15 @@ export interface ErrorMessage { property?: string; } +export type ResultInstance = { + version?: string; + url?: string; +} & NamedRef; + export interface SynchronizationResult { status: SynchronizationStatus; - origin?: NamedRef | Store; // TODO: Create union - instance: NamedRef; + origin?: ResultInstance | Store; // TODO: Create union + instance: ResultInstance; originPackage?: NamedRef; date: Date; type: SynchronizationResultType; diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index 22417789b..d4df43611 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -129,8 +129,10 @@ import { getD2APiFromInstance } from "../utils/d2-utils"; import { RoleD2ApiRepository } from "../data/role/RoleD2ApiRepository"; import { ValidateRolesUseCase } from "../domain/role/ValidateRolesUseCase"; import { D2Api } from "@eyeseetea/d2-api/2.36"; -import { SendEmailUseCase } from "../domain/email/SendEmailUseCase"; +import { SendEmailUseCase } from "../domain/email/usecases/SendEmailUseCase"; import { EmailD2ApiRepository } from "../data/email/EmailD2ApiRepository"; +import { AttachFileUseCase } from "../domain/email/usecases/AttachFileUseCase"; +import { AttachedFileD2ApiRepository } from "../data/email/AttachedFileD2ApiRepository"; export class CompositionRoot { private repositoryFactory: RepositoryFactory; @@ -442,6 +444,7 @@ export class CompositionRoot { public get email() { return getExecute({ send: new SendEmailUseCase(new EmailD2ApiRepository(this.api)), + attachFile: new AttachFileUseCase(new AttachedFileD2ApiRepository(this.api)), }); } diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx index 9d9730588..7e80f155e 100644 --- a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -12,7 +12,7 @@ interface SyncSummaryProps { } export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { - const state = useShareSyncError(); + const state = useShareSyncError(errorResults); const snackbar = useSnackbar(); const loading = useLoading(); @@ -32,6 +32,14 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { } }, [state.sending, loading]); + useEffect(() => { + if (state.attachingFiles) { + loading.show(); + } else { + loading.hide(); + } + }, [state.attachingFiles, loading]); + const handleToChange = (event: React.ChangeEvent) => { const { value } = event.target; diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index 3d2247748..84eada01a 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -1,6 +1,10 @@ import i18n from "@eyeseetea/feedback-component/locales"; -import { useCallback, useState } from "react"; -import { Email } from "../../../../../domain/email/Email"; +import { useCallback, useEffect, useState } from "react"; +import { Future } from "../../../../../domain/common/entities/Future"; +import { AttachedFile } from "../../../../../domain/email/entities/AttachedFile"; +import { Email } from "../../../../../domain/email/entities/Email"; +import { ResultInstance, SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; +import { Store } from "../../../../../domain/stores/entities/Store"; import { useAppContext } from "../../contexts/AppContext"; type ShareSyncState = { @@ -9,6 +13,7 @@ type ShareSyncState = { text: string; messageToUser: MessageToUser | undefined; sending: boolean; + attachingFiles: boolean; onToChange: (to: string) => void; onSubjectChange: (subject: string) => void; onTextChange: (message: string) => void; @@ -20,15 +25,65 @@ type MessageToUser = { type: "error" | "success"; }; -export function useShareSyncError(): ShareSyncState { +export function useShareSyncError(errorResults: SynchronizationResult[]): ShareSyncState { const [to, setTo] = useState(""); const [subject, setSubject] = useState(""); const [text, setText] = useState(""); const [messageToUser, setMessageToUser] = useState(); const [sending, setSending] = useState(false); + const [attachingFiles, setAttachingFiles] = useState(false); + const [attachedFiles, setAttachedFiles] = useState([]); const { compositionRoot } = useAppContext(); + useEffect(() => { + const initialText = errorResults + .map(result => { + return `Error encountered when trying a ${ + result.type + } migration in MetaData Sync between servers origin: ${getServerInfo( + result.origin + )} and destination: ${getServerInfo(result.instance)}. See the payload and summary below.\n`; + }) + .join("\n"); + + const attachesFileTexts = attachedFiles.map(file => { + return `${file.name} - ${file.url}`; + }); + + setText(`${initialText}\n${attachesFileTexts.join("\n")}`); + }, [attachedFiles, errorResults]); + + useEffect(() => { + setAttachingFiles(true); + + const futures = errorResults + .map(result => { + return [ + compositionRoot.email.attachFile({ + name: `${result.type}-payload.json`, + data: createJsonBobByObject(result.payload), + }), + compositionRoot.email.attachFile({ + name: `${result.type}-summary.json`, + data: createJsonBobByObject(result.response), + }), + ]; + }) + .flat(); + + Future.parallel(futures).run( + files => { + setAttachingFiles(false); + setAttachedFiles(files); + }, + error => { + setAttachingFiles(false); + setMessageToUser({ message: error, type: "error" }); + } + ); + }, [compositionRoot.email, errorResults]); + const onToChange = useCallback((to: string) => { setTo(to); }, []); @@ -68,9 +123,28 @@ export function useShareSyncError(): ShareSyncState { text, messageToUser, sending, + attachingFiles, onToChange, onSubjectChange, onTextChange, onSendEmail, }; } + +function createJsonBobByObject(object: unknown): Blob { + const json = JSON.stringify(object); + return new Blob([json], { type: "application/json" }); +} +function getServerInfo(origin: ResultInstance | Store | undefined): string { + if (!origin) return "Unknown server"; + + if ("version" in origin && "url" in origin) { + return `${origin.url} (v${origin.version})`; + } + + if ("repository" in origin) { + return origin.repository; + } + + return "Unknown server"; +} From 562d90c8a009558f303d1ca0cc38744ace0b1639 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 6 Mar 2025 12:17:43 +0100 Subject: [PATCH 06/15] Create EmailInput component --- .../components/email-input/EmailInput.tsx | 78 +++++++++++++++++++ .../components/email-input/useEmailInput.ts | 74 ++++++++++++++++++ .../share-sync-error/ShareSyncError.tsx | 16 ++-- .../share-sync-error/useShareSyncError.ts | 14 ++-- 4 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 src/presentation/react/core/components/email-input/EmailInput.tsx create mode 100644 src/presentation/react/core/components/email-input/useEmailInput.ts diff --git a/src/presentation/react/core/components/email-input/EmailInput.tsx b/src/presentation/react/core/components/email-input/EmailInput.tsx new file mode 100644 index 000000000..f1ed1be54 --- /dev/null +++ b/src/presentation/react/core/components/email-input/EmailInput.tsx @@ -0,0 +1,78 @@ +import React, { KeyboardEvent, ChangeEvent, useCallback } from "react"; +import styled from "styled-components"; +import { TextField, Chip, TextFieldProps } from "@material-ui/core"; +import { useEmailInput } from "./useEmailInput"; + +interface EmailInputProps extends Omit { + emails?: string[]; + text?: string; + onEmailsChange?: (emails: string[]) => void; + onTextChange?: (text: string) => void; +} + +export const EmailInput: React.FC = ({ + emails: propEmails, + text: propText, + onEmailsChange, + onTextChange, + ...textFieldProps +}) => { + // Estados internos del custom hook + const { internalText, internalEmails, handleInternalEmailInputChange, tryAddEmail, handleDelete } = useEmailInput( + propEmails, + propText, + onEmailsChange, + onTextChange + ); + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.key === "Enter" || event.key === "Tab") && internalText.trim()) { + tryAddEmail(internalText); + event.preventDefault(); + } + }; + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + handleInternalEmailInputChange(e.target.value); + }, + [handleInternalEmailInputChange] + ); + + return ( + + {internalEmails.map((email, index) => { + return ( + handleDelete(email)} + /> + ); + })} + + ), + }} + /> + ); +}; + +const ChipContainer = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 8px; +`; + +export const SelectedChip = styled(Chip)` + border-radius: 20px; + border: none; + margin: 1px; +`; diff --git a/src/presentation/react/core/components/email-input/useEmailInput.ts b/src/presentation/react/core/components/email-input/useEmailInput.ts new file mode 100644 index 000000000..cd87e2d38 --- /dev/null +++ b/src/presentation/react/core/components/email-input/useEmailInput.ts @@ -0,0 +1,74 @@ +import { useState, useCallback, useEffect } from "react"; + +export function useEmailInput( + emails?: string[], + text?: string, + onEmailsChange?: (emails: string[]) => void, + onTextChange?: (text: string) => void +) { + const [internalText, setInternalText] = useState(text || ""); + const [internalEmails, setInternalEmails] = useState(emails || []); + + useEffect(() => { + setInternalEmails(emails || []); + }, [emails]); + + useEffect(() => { + setInternalText(text || ""); + }, [text]); + + const tryAddEmail = useCallback( + (email: string) => { + if (isValidEmail(email.trim())) { + const emails = [...internalEmails, email.trim()]; + setInternalEmails(emails); + setInternalText(""); + + if (onEmailsChange) { + onEmailsChange(emails); + } + + if (onTextChange) { + onTextChange(internalText); + } + } + }, + [internalEmails, internalText, onEmailsChange, onTextChange] + ); + + const handleDelete = useCallback( + (emailToDelete: string) => { + const emails = internalEmails.filter(email => email !== emailToDelete); + setInternalEmails(emails); + + if (onEmailsChange) { + onEmailsChange(emails); + } + }, + [internalEmails, onEmailsChange] + ); + + const handleInternalEmailInputChange = useCallback( + (text: string) => { + setInternalText(text); + + if (onTextChange) { + onTextChange(internalText); + } + }, + [internalText, onTextChange] + ); + + return { + internalText, + internalEmails, + tryAddEmail, + handleDelete, + handleInternalEmailInputChange, + }; +} + +// TODO: This is a temp function. This logic should be in domain +function isValidEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx index 7e80f155e..0ca5d92a7 100644 --- a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -5,6 +5,7 @@ import i18n from "../../../../../locales"; import React, { useEffect } from "react"; import styled from "styled-components"; import { useShareSyncError } from "./useShareSyncError"; +import { EmailInput } from "../email-input/EmailInput"; interface SyncSummaryProps { errorResults: SynchronizationResult[]; @@ -40,12 +41,6 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { } }, [state.attachingFiles, loading]); - const handleToChange = (event: React.ChangeEvent) => { - const { value } = event.target; - - state.onToChange(value); - }; - const handleSubjectChange = (event: React.ChangeEvent) => { const { value } = event.target; @@ -71,16 +66,18 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { > - + { void; + onToChange: (to: string[]) => void; onSubjectChange: (subject: string) => void; onTextChange: (message: string) => void; onSendEmail: () => void; @@ -26,8 +26,8 @@ type MessageToUser = { }; export function useShareSyncError(errorResults: SynchronizationResult[]): ShareSyncState { - const [to, setTo] = useState(""); - const [subject, setSubject] = useState(""); + const [to, setTo] = useState([]); + const [subject, setSubject] = useState("Error encountered when trying a migration in MetaData Sync"); const [text, setText] = useState(""); const [messageToUser, setMessageToUser] = useState(); const [sending, setSending] = useState(false); @@ -48,7 +48,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS .join("\n"); const attachesFileTexts = attachedFiles.map(file => { - return `${file.name} - ${file.url}`; + return `- ${file.name}: ${file.url}`; }); setText(`${initialText}\n${attachesFileTexts.join("\n")}`); @@ -84,7 +84,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS ); }, [compositionRoot.email, errorResults]); - const onToChange = useCallback((to: string) => { + const onToChange = useCallback((to: string[]) => { setTo(to); }, []); @@ -98,7 +98,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS const onSendEmail = useCallback(async () => { const email = Email.create({ - recipients: to.split(","), + recipients: to, subject, text, }); From ba472ef8be8a2deccc88c8f758524c5f48e80ead Mon Sep 17 00:00:00 2001 From: xurxodev Date: Fri, 7 Mar 2025 07:14:22 +0100 Subject: [PATCH 07/15] Update translations --- i18n/en.pot | 22 +++++++++++++++++----- i18n/es.po | 20 ++++++++++++++++---- i18n/fr.po | 20 ++++++++++++++++---- i18n/pt.po | 20 ++++++++++++++++---- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 7c01dd2d4..9e8df1d75 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,11 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" -"PO-Revision-Date: 2025-03-05T10:13:18.576Z\n" +"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" +"PO-Revision-Date: 2025-03-06T12:38:40.098Z\n" + +msgid "Unknown error to send email" +msgstr "" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -1000,6 +1003,9 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending email" +msgstr "" + msgid "Share error information" msgstr "" @@ -1009,6 +1015,15 @@ msgstr "" msgid "Send" msgstr "" +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "Email sending successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1119,9 +1134,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index a26798919..f6cd976c0 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" +"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Unknown error to send email" +msgstr "" + msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " "TO WORK PROPERLY, YOU WILL HAVE TO SET THE SHARING SETTINGS FOR EACH " @@ -1002,6 +1005,9 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending email" +msgstr "" + msgid "Share error information" msgstr "" @@ -1011,6 +1017,15 @@ msgstr "" msgid "Send" msgstr "" +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "Email sending successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1123,9 +1138,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 9a83a87dd..2daa1936a 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" +"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Unknown error to send email" +msgstr "" + msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " "TO WORK PROPERLY, YOU WILL HAVE TO SET THE SHARING SETTINGS FOR EACH " @@ -1001,6 +1004,9 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending email" +msgstr "" + msgid "Share error information" msgstr "" @@ -1010,6 +1016,15 @@ msgstr "" msgid "Send" msgstr "" +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "Email sending successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1122,9 +1137,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 9a83a87dd..2daa1936a 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,13 +1,16 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-05T10:13:18.576Z\n" +"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Unknown error to send email" +msgstr "" + msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " "TO WORK PROPERLY, YOU WILL HAVE TO SET THE SHARING SETTINGS FOR EACH " @@ -1001,6 +1004,9 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending email" +msgstr "" + msgid "Share error information" msgstr "" @@ -1010,6 +1016,15 @@ msgstr "" msgid "Send" msgstr "" +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "Email sending successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1122,9 +1137,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" From c86b30be013a25b6937484c9980eacd0a296417c Mon Sep 17 00:00:00 2001 From: xurxodev Date: Sun, 9 Mar 2025 12:24:37 +0100 Subject: [PATCH 08/15] Rename folder from email to comunications --- .../AttachedFileD2ApiRepository.ts | 4 ++-- .../EmailD2ApiRepository.ts | 4 ++-- .../entities/AttachedFile.ts | 0 .../{email => comunications}/entities/Email.ts | 0 .../repositories/AttachedFileRepository.ts | 0 .../repositories/EmailRepository.ts | 0 .../usecases/AttachFileUseCase.ts | 0 .../usecases/SendEmailUseCase.ts | 0 src/presentation/CompositionRoot.ts | 14 ++++++++------ .../share-sync-error/useShareSyncError.ts | 14 +++++++------- 10 files changed, 19 insertions(+), 17 deletions(-) rename src/data/{email => comunications}/AttachedFileD2ApiRepository.ts (84%) rename src/data/{email => comunications}/EmailD2ApiRepository.ts (71%) rename src/domain/{email => comunications}/entities/AttachedFile.ts (100%) rename src/domain/{email => comunications}/entities/Email.ts (100%) rename src/domain/{email => comunications}/repositories/AttachedFileRepository.ts (100%) rename src/domain/{email => comunications}/repositories/EmailRepository.ts (100%) rename src/domain/{email => comunications}/usecases/AttachFileUseCase.ts (100%) rename src/domain/{email => comunications}/usecases/SendEmailUseCase.ts (100%) diff --git a/src/data/email/AttachedFileD2ApiRepository.ts b/src/data/comunications/AttachedFileD2ApiRepository.ts similarity index 84% rename from src/data/email/AttachedFileD2ApiRepository.ts rename to src/data/comunications/AttachedFileD2ApiRepository.ts index c24eb57e6..b6373083b 100644 --- a/src/data/email/AttachedFileD2ApiRepository.ts +++ b/src/data/comunications/AttachedFileD2ApiRepository.ts @@ -1,6 +1,6 @@ import { Future, FutureData } from "../../domain/common/entities/Future"; -import { AttachedFile, AttachedFileInput } from "../../domain/email/entities/AttachedFile"; -import { AttachedFileRepository } from "../../domain/email/repositories/AttachedFileRepository"; +import { AttachedFile, AttachedFileInput } from "../../domain/comunications/entities/AttachedFile"; +import { AttachedFileRepository } from "../../domain/comunications/repositories/AttachedFileRepository"; import { D2Api } from "../../types/d2-api"; import { apiToFuture } from "../common/utils/futures"; diff --git a/src/data/email/EmailD2ApiRepository.ts b/src/data/comunications/EmailD2ApiRepository.ts similarity index 71% rename from src/data/email/EmailD2ApiRepository.ts rename to src/data/comunications/EmailD2ApiRepository.ts index c4e2c109f..253af37cc 100644 --- a/src/data/email/EmailD2ApiRepository.ts +++ b/src/data/comunications/EmailD2ApiRepository.ts @@ -1,5 +1,5 @@ -import { Email } from "../../domain/email/entities/Email"; -import { EmailRepository } from "../../domain/email/repositories/EmailRepository"; +import { Email } from "../../domain/comunications/entities/Email"; +import { EmailRepository } from "../../domain/comunications/repositories/EmailRepository"; import { D2Api } from "../../types/d2-api"; import { apiToFuture } from "../common/utils/futures"; import { FutureData } from "../../domain/common/entities/Future"; diff --git a/src/domain/email/entities/AttachedFile.ts b/src/domain/comunications/entities/AttachedFile.ts similarity index 100% rename from src/domain/email/entities/AttachedFile.ts rename to src/domain/comunications/entities/AttachedFile.ts diff --git a/src/domain/email/entities/Email.ts b/src/domain/comunications/entities/Email.ts similarity index 100% rename from src/domain/email/entities/Email.ts rename to src/domain/comunications/entities/Email.ts diff --git a/src/domain/email/repositories/AttachedFileRepository.ts b/src/domain/comunications/repositories/AttachedFileRepository.ts similarity index 100% rename from src/domain/email/repositories/AttachedFileRepository.ts rename to src/domain/comunications/repositories/AttachedFileRepository.ts diff --git a/src/domain/email/repositories/EmailRepository.ts b/src/domain/comunications/repositories/EmailRepository.ts similarity index 100% rename from src/domain/email/repositories/EmailRepository.ts rename to src/domain/comunications/repositories/EmailRepository.ts diff --git a/src/domain/email/usecases/AttachFileUseCase.ts b/src/domain/comunications/usecases/AttachFileUseCase.ts similarity index 100% rename from src/domain/email/usecases/AttachFileUseCase.ts rename to src/domain/comunications/usecases/AttachFileUseCase.ts diff --git a/src/domain/email/usecases/SendEmailUseCase.ts b/src/domain/comunications/usecases/SendEmailUseCase.ts similarity index 100% rename from src/domain/email/usecases/SendEmailUseCase.ts rename to src/domain/comunications/usecases/SendEmailUseCase.ts diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index d4df43611..f5ec289f7 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -129,10 +129,12 @@ import { getD2APiFromInstance } from "../utils/d2-utils"; import { RoleD2ApiRepository } from "../data/role/RoleD2ApiRepository"; import { ValidateRolesUseCase } from "../domain/role/ValidateRolesUseCase"; import { D2Api } from "@eyeseetea/d2-api/2.36"; -import { SendEmailUseCase } from "../domain/email/usecases/SendEmailUseCase"; -import { EmailD2ApiRepository } from "../data/email/EmailD2ApiRepository"; -import { AttachFileUseCase } from "../domain/email/usecases/AttachFileUseCase"; -import { AttachedFileD2ApiRepository } from "../data/email/AttachedFileD2ApiRepository"; +import { SendEmailUseCase } from "../domain/comunications/usecases/SendEmailUseCase"; +import { EmailD2ApiRepository } from "../data/comunications/EmailD2ApiRepository"; +import { AttachFileUseCase } from "../domain/comunications/usecases/AttachFileUseCase"; +import { AttachedFileD2ApiRepository } from "../data/comunications/AttachedFileD2ApiRepository"; +import { SendMessageUseCase } from "../domain/comunications/usecases/SendMessageUseCase"; +import { MessageD2ApiRepository } from "../data/comunications/MessageD2ApiRepository"; export class CompositionRoot { private repositoryFactory: RepositoryFactory; @@ -441,9 +443,9 @@ export class CompositionRoot { } @cache() - public get email() { + public get comunications() { return getExecute({ - send: new SendEmailUseCase(new EmailD2ApiRepository(this.api)), + sendEmail: new SendEmailUseCase(new EmailD2ApiRepository(this.api)), attachFile: new AttachFileUseCase(new AttachedFileD2ApiRepository(this.api)), }); } diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index cb2e318d1..2a1fa51a8 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -1,8 +1,8 @@ import i18n from "@eyeseetea/feedback-component/locales"; import { useCallback, useEffect, useState } from "react"; import { Future } from "../../../../../domain/common/entities/Future"; -import { AttachedFile } from "../../../../../domain/email/entities/AttachedFile"; -import { Email } from "../../../../../domain/email/entities/Email"; +import { AttachedFile } from "../../../../../domain/comunications/entities/AttachedFile"; +import { Email } from "../../../../../domain/comunications/entities/Email"; import { ResultInstance, SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; import { Store } from "../../../../../domain/stores/entities/Store"; import { useAppContext } from "../../contexts/AppContext"; @@ -60,11 +60,11 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS const futures = errorResults .map(result => { return [ - compositionRoot.email.attachFile({ + compositionRoot.comunications.attachFile({ name: `${result.type}-payload.json`, data: createJsonBobByObject(result.payload), }), - compositionRoot.email.attachFile({ + compositionRoot.comunications.attachFile({ name: `${result.type}-summary.json`, data: createJsonBobByObject(result.response), }), @@ -82,7 +82,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS setMessageToUser({ message: error, type: "error" }); } ); - }, [compositionRoot.email, errorResults]); + }, [compositionRoot.comunications, errorResults]); const onToChange = useCallback((to: string[]) => { setTo(to); @@ -105,7 +105,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS setSending(true); - compositionRoot.email.send(email).run( + compositionRoot.comunications.sendEmail(email).run( () => { setSending(false); setMessageToUser({ message: i18n.t("Email sending successfully"), type: "success" }); @@ -115,7 +115,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS setMessageToUser({ message: error, type: "error" }); } ); - }, [compositionRoot.email, text, subject, to]); + }, [to, subject, text, compositionRoot.comunications]); return { to, From 1b9c719d04460377c991a3e911a7ba24590169f6 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Mon, 10 Mar 2025 06:47:42 +0100 Subject: [PATCH 09/15] Create infrastructure to send message to users or user groups --- i18n/en.pot | 7 +- i18n/es.po | 5 +- i18n/fr.po | 5 +- i18n/pt.po | 5 +- .../comunications/MessageD2ApiRepository.ts | 73 ++++++++++++ src/domain/comunications/entities/Message.ts | 15 +++ .../repositories/MessageRepository.ts | 7 ++ .../SearchMessageRecipientsUseCase.ts | 11 ++ .../usecases/SendMessageUseCase.ts | 11 ++ src/presentation/CompositionRoot.ts | 4 + .../components/email-input/EmailInput.tsx | 1 - .../message-recipients/MessageRecipients.tsx | 105 ++++++++++++++++++ .../useMessageRecipients.ts | 91 +++++++++++++++ .../share-sync-error/ShareSyncError.tsx | 42 +++++-- .../share-sync-error/useShareSyncError.ts | 96 +++++++++++++--- 15 files changed, 442 insertions(+), 36 deletions(-) create mode 100644 src/data/comunications/MessageD2ApiRepository.ts create mode 100644 src/domain/comunications/entities/Message.ts create mode 100644 src/domain/comunications/repositories/MessageRepository.ts create mode 100644 src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts create mode 100644 src/domain/comunications/usecases/SendMessageUseCase.ts create mode 100644 src/presentation/react/core/components/message-recipients/MessageRecipients.tsx create mode 100644 src/presentation/react/core/components/message-recipients/useMessageRecipients.ts diff --git a/i18n/en.pot b/i18n/en.pot index 9e8df1d75..b7e2300a1 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" -"PO-Revision-Date: 2025-03-06T12:38:40.098Z\n" +"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" +"PO-Revision-Date: 2025-03-09T16:18:49.808Z\n" msgid "Unknown error to send email" msgstr "" @@ -1021,6 +1021,9 @@ msgstr "" msgid "Message" msgstr "" +msgid "Email" +msgstr "" + msgid "Email sending successfully" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index f6cd976c0..5151afe1e 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" +"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1023,6 +1023,9 @@ msgstr "" msgid "Message" msgstr "" +msgid "Email" +msgstr "" + msgid "Email sending successfully" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 2daa1936a..fd9d22802 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" +"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1022,6 +1022,9 @@ msgstr "" msgid "Message" msgstr "" +msgid "Email" +msgstr "" + msgid "Email sending successfully" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 2daa1936a..fd9d22802 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-06T12:38:40.098Z\n" +"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1022,6 +1022,9 @@ msgstr "" msgid "Message" msgstr "" +msgid "Email" +msgstr "" + msgid "Email sending successfully" msgstr "" diff --git a/src/data/comunications/MessageD2ApiRepository.ts b/src/data/comunications/MessageD2ApiRepository.ts new file mode 100644 index 000000000..45d786c17 --- /dev/null +++ b/src/data/comunications/MessageD2ApiRepository.ts @@ -0,0 +1,73 @@ +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { apiToFuture } from "../common/utils/futures"; +import { Future, FutureData } from "../../domain/common/entities/Future"; +import { MessageRepository } from "../../domain/comunications/repositories/MessageRepository"; +import { Message, MessageRecipient } from "../../domain/comunications/entities/Message"; +import { PostMessage } from "@eyeseetea/d2-api/api/messageConversations"; + +export class MessageD2ApiRepository implements MessageRepository { + constructor(private readonly d2Api: D2Api) {} + + searchRecipients(text: string): FutureData { + return Future.joinObj({ + users: apiToFuture( + this.d2Api.models.users.get({ + fields: fields, + paging: false, + filter: { displayName: { like: text } }, + }) + ).map(response => response.objects.map(this.buildUserRecipient)), + userGroups: apiToFuture( + this.d2Api.models.userGroups.get({ + fields: fields, + paging: false, + filter: { displayName: { like: text } }, + }) + ).map(response => response.objects.map(this.buildUserGroupRecipient)), + }).flatMap(responses => Future.success([...responses.users, ...responses.userGroups])); + } + + send(message: Message): FutureData { + return apiToFuture(this.d2Api.messageConversations.post(this.buildPostMessage(message))); + } + + buildPostMessage(message: Message): PostMessage { + return { + subject: message.subject, + text: message.text, + users: message.recipients + .filter(recipient => recipient.type === "User") + .map(recipient => ({ id: recipient.id })), + userGroups: message.recipients + .filter(recipient => recipient.type === "UserGroup") + .map(recipient => ({ id: recipient.id })), + organisationUnits: [], + }; + } + + buildUserRecipient(user: D2User): MessageRecipient { + return { + type: "User", + id: user.id, + name: user.displayName, + }; + } + + buildUserGroupRecipient(userGroup: D2UserGroup): MessageRecipient { + return { + type: "UserGroup", + id: userGroup.id, + name: userGroup.displayName, + }; + } +} + +const fields = { id: true, displayName: true } as const; + +type D2UserGroup = MetadataPick<{ + userGroups: { fields: typeof fields }; +}>["userGroups"][number]; + +type D2User = MetadataPick<{ + userGroups: { fields: typeof fields }; +}>["userGroups"][number]; diff --git a/src/domain/comunications/entities/Message.ts b/src/domain/comunications/entities/Message.ts new file mode 100644 index 000000000..5a5befdc3 --- /dev/null +++ b/src/domain/comunications/entities/Message.ts @@ -0,0 +1,15 @@ +import { Struct } from "../../common/entities/Struct"; + +export type MessageRecipient = { + id: string; + name: string; + type: "User" | "UserGroup"; +}; + +export type MessageProps = { + subject: string; + text: string; + recipients: MessageRecipient[]; +}; + +export class Message extends Struct() {} diff --git a/src/domain/comunications/repositories/MessageRepository.ts b/src/domain/comunications/repositories/MessageRepository.ts new file mode 100644 index 000000000..24e639b9d --- /dev/null +++ b/src/domain/comunications/repositories/MessageRepository.ts @@ -0,0 +1,7 @@ +import { FutureData } from "../../common/entities/Future"; +import { Message, MessageRecipient } from "../entities/Message"; + +export interface MessageRepository { + searchRecipients(text: string): FutureData; + send(message: Message): FutureData; +} diff --git a/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts b/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts new file mode 100644 index 000000000..b82b6d769 --- /dev/null +++ b/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../common/entities/Future"; +import { MessageRecipient } from "../entities/Message"; +import { MessageRepository } from "../repositories/MessageRepository"; + +export class SearchMessageRecipientsUseCase { + constructor(private messageRepository: MessageRepository) {} + + execute(text: string): FutureData { + return this.messageRepository.searchRecipients(text); + } +} diff --git a/src/domain/comunications/usecases/SendMessageUseCase.ts b/src/domain/comunications/usecases/SendMessageUseCase.ts new file mode 100644 index 000000000..4a60c31d5 --- /dev/null +++ b/src/domain/comunications/usecases/SendMessageUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../common/entities/Future"; +import { Message } from "../entities/Message"; +import { MessageRepository } from "../repositories/MessageRepository"; + +export class SendMessageUseCase { + constructor(private messageRepository: MessageRepository) {} + + execute(message: Message): FutureData { + return this.messageRepository.send(message); + } +} diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index f5ec289f7..2e4efa56a 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -135,6 +135,7 @@ import { AttachFileUseCase } from "../domain/comunications/usecases/AttachFileUs import { AttachedFileD2ApiRepository } from "../data/comunications/AttachedFileD2ApiRepository"; import { SendMessageUseCase } from "../domain/comunications/usecases/SendMessageUseCase"; import { MessageD2ApiRepository } from "../data/comunications/MessageD2ApiRepository"; +import { SearchMessageRecipientsUseCase } from "../domain/comunications/usecases/SearchMessageRecipientsUseCase"; export class CompositionRoot { private repositoryFactory: RepositoryFactory; @@ -444,8 +445,11 @@ export class CompositionRoot { @cache() public get comunications() { + const messageRepository = new MessageD2ApiRepository(this.api); return getExecute({ sendEmail: new SendEmailUseCase(new EmailD2ApiRepository(this.api)), + sendMessage: new SendMessageUseCase(messageRepository), + searchMessageRecipients: new SearchMessageRecipientsUseCase(messageRepository), attachFile: new AttachFileUseCase(new AttachedFileD2ApiRepository(this.api)), }); } diff --git a/src/presentation/react/core/components/email-input/EmailInput.tsx b/src/presentation/react/core/components/email-input/EmailInput.tsx index f1ed1be54..b5d2e4e36 100644 --- a/src/presentation/react/core/components/email-input/EmailInput.tsx +++ b/src/presentation/react/core/components/email-input/EmailInput.tsx @@ -17,7 +17,6 @@ export const EmailInput: React.FC = ({ onTextChange, ...textFieldProps }) => { - // Estados internos del custom hook const { internalText, internalEmails, handleInternalEmailInputChange, tryAddEmail, handleDelete } = useEmailInput( propEmails, propText, diff --git a/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx b/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx new file mode 100644 index 000000000..c7b71de12 --- /dev/null +++ b/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx @@ -0,0 +1,105 @@ +import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { TextField, Chip, TextFieldProps, Menu, MenuItem } from "@material-ui/core"; +import { MessageRecipient } from "../../../../../domain/comunications/entities/Message"; +import { useMessageRecipients } from "./useMessageRecipients"; + +interface MessageRecipientsProps extends Omit { + recipients?: MessageRecipient[]; + text?: string; + onRecipientsChange?: (recipients: MessageRecipient[]) => void; + onTextChange?: (text: string) => void; +} + +export const MessageRecipients: React.FC = ({ + recipients: propRecipients, + text: propText, + onRecipientsChange, + onTextChange, + ...textFieldProps +}) => { + const { + internalText, + internalRecipients, + recipientCandidates, + handleInternalTextChange, + handleDelete, + onSelectCantidate, + } = useMessageRecipients(propRecipients, propText, onRecipientsChange, onTextChange); + + const [anchorEl, setAnchorEl] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (recipientCandidates.length > 0) { + setAnchorEl(ref.current); + } else { + setAnchorEl(null); + } + }, [internalText, recipientCandidates.length]); + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + handleInternalTextChange(e.target.value); + }, + [handleInternalTextChange] + ); + + const handleCloseOptionsMenu = useCallback(() => { + setAnchorEl(null); + }, []); + + return ( + <> + + {internalRecipients.map((recipient, index) => { + return ( + handleDelete(recipient.id)} + /> + ); + })} + + ), + }} + /> + + {recipientCandidates.map((candidate, index) => { + return ( + onSelectCantidate(candidate.id)}> + {candidate.name} + + ); + })} + + + ); +}; + +const ChipContainer = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 8px; +`; + +export const SelectedChip = styled(Chip)` + border-radius: 20px; + border: none; + margin: 1px; +`; diff --git a/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts b/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts new file mode 100644 index 000000000..72c182ee0 --- /dev/null +++ b/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts @@ -0,0 +1,91 @@ +import { useState, useCallback, useEffect } from "react"; +import { MessageRecipient } from "../../../../../domain/comunications/entities/Message"; +import { useAppContext } from "../../contexts/AppContext"; + +export function useMessageRecipients( + recipients?: MessageRecipient[], + text?: string, + onRecipientsChange?: (recipients: MessageRecipient[]) => void, + onTextChange?: (text: string) => void +) { + const { compositionRoot } = useAppContext(); + const [internalText, setInternalText] = useState(text || ""); + const [internalRecipients, setInternalRecipients] = useState(recipients || []); + const [recipientCandidates, setRecipientCandidates] = useState([]); + + useEffect(() => { + setInternalRecipients(recipients || []); + }, [recipients]); + + useEffect(() => { + setInternalText(text || ""); + }, [text]); + + const handleDelete = useCallback( + (recipientId: string) => { + const emails = internalRecipients.filter(recipient => recipient.id !== recipientId); + setInternalRecipients(emails); + + if (onRecipientsChange) { + onRecipientsChange(emails); + } + }, + [internalRecipients, onRecipientsChange] + ); + + const onSelectCantidate = useCallback( + (cantidateId: string) => { + const candidate = recipientCandidates.find(recipient => recipient.id === cantidateId); + + if (candidate) { + const recipìents = [...internalRecipients, candidate]; + + setInternalRecipients(recipìents); + setRecipientCandidates([]); + setInternalText(""); + + if (onRecipientsChange) { + onRecipientsChange(recipìents); + } + } + }, + [internalRecipients, onRecipientsChange, recipientCandidates] + ); + + useEffect(() => { + if (internalText.length <= 1) return; + + setRecipientCandidates([]); + + const cancel = compositionRoot.comunications.searchMessageRecipients(internalText).run( + recipients => { + setRecipientCandidates(recipients); + }, + error => { + console.error(error); + } + ); + + return cancel; + }, [compositionRoot.comunications, internalText, text]); + + const handleInternalTextChange = useCallback( + (text: string) => { + setInternalText(text); + + if (onTextChange) { + onTextChange(internalText); + } + }, + [internalText, onTextChange] + ); + + return { + internalText, + internalRecipients, + recipientCandidates, + handleDelete, + handleInternalTextChange, + onSelectCantidate, + }; +} diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx index 0ca5d92a7..5f798fc6c 100644 --- a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -4,8 +4,10 @@ import { SynchronizationResult } from "../../../../../domain/reports/entities/Sy import i18n from "../../../../../locales"; import React, { useEffect } from "react"; import styled from "styled-components"; -import { useShareSyncError } from "./useShareSyncError"; +import { ShareSyncType, useShareSyncError } from "./useShareSyncError"; import { EmailInput } from "../email-input/EmailInput"; +import RadioButtonGroup from "../radio-button-group/RadioButtonGroup"; +import { MessageRecipients } from "../message-recipients/MessageRecipients"; interface SyncSummaryProps { errorResults: SynchronizationResult[]; @@ -27,7 +29,7 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { useEffect(() => { if (state.sending) { - loading.show(true, i18n.t("Sending email")); + loading.show(true, i18n.t("Sending")); } else { loading.hide(); } @@ -44,13 +46,17 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { const handleSubjectChange = (event: React.ChangeEvent) => { const { value } = event.target; - state.onSubjectChange(value); + state.changeSubject(value); }; const handleMessageChange = (event: React.ChangeEvent) => { const { value } = event.target; - state.onTextChange(value); + state.changeText(value); + }; + + const handleChangeType = (type: string) => { + state.changeType(type as ShareSyncType); }; return ( @@ -58,21 +64,32 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { isOpen={true} title={i18n.t("Share error information")} onCancel={onClose} - onSave={state.onSendEmail} + onSave={state.send} cancelText={i18n.t("Discard")} saveText={i18n.t("Send")} maxWidth={"lg"} fullWidth={true} > + - + {state.type === "Email" ? ( + + ) : ( + + )} void; - onSubjectChange: (subject: string) => void; - onTextChange: (message: string) => void; - onSendEmail: () => void; + changeType: (type: ShareSyncType) => void; + changeToEmail: (to: string[]) => void; + changeToMessageRecipients: (to: MessageRecipient[]) => void; + changeSubject: (subject: string) => void; + changeText: (message: string) => void; + send: () => void; + typeOptions: TypeOption[]; }; +const typeOptions: TypeOption[] = [ + { id: "Email", name: i18n.t("Email") }, + { id: "Message", name: i18n.t("Message") }, +]; + +type TypeOption = { id: string; name: string }; + +export type ShareSyncType = "Email" | "Message"; + type MessageToUser = { message: string; type: "error" | "success"; }; export function useShareSyncError(errorResults: SynchronizationResult[]): ShareSyncState { - const [to, setTo] = useState([]); + const [type, setType] = useState("Email"); + const [toEmail, setToEmail] = useState([]); + const [toMessageRecipients, setToMessageRecipients] = useState([]); const [subject, setSubject] = useState("Error encountered when trying a migration in MetaData Sync"); const [text, setText] = useState(""); const [messageToUser, setMessageToUser] = useState(); @@ -84,21 +101,30 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS ); }, [compositionRoot.comunications, errorResults]); - const onToChange = useCallback((to: string[]) => { - setTo(to); + const changeType = useCallback((type: ShareSyncType) => { + setType(type); + setToEmail([]); + }, []); + + const changeToEmail = useCallback((to: string[]) => { + setToEmail(to); + }, []); + + const changeToMessageRecipients = useCallback((recipients: MessageRecipient[]) => { + setToMessageRecipients(recipients); }, []); const onSubjectChange = useCallback((subject: string) => { setSubject(subject); }, []); - const onTextChange = useCallback((message: string) => { + const changeText = useCallback((message: string) => { setText(message); }, []); - const onSendEmail = useCallback(async () => { + const sendEmail = useCallback(async () => { const email = Email.create({ - recipients: to, + recipients: toEmail, subject, text, }); @@ -108,26 +134,60 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS compositionRoot.comunications.sendEmail(email).run( () => { setSending(false); - setMessageToUser({ message: i18n.t("Email sending successfully"), type: "success" }); + setMessageToUser({ message: i18n.t("Email sent successfully"), type: "success" }); }, error => { setSending(false); setMessageToUser({ message: error, type: "error" }); } ); - }, [to, subject, text, compositionRoot.comunications]); + }, [toEmail, subject, text, compositionRoot.comunications]); + + const sendMessage = useCallback(async () => { + const message = Message.create({ + recipients: toMessageRecipients, + subject, + text, + }); + + setSending(true); + + compositionRoot.comunications.sendMessage(message).run( + () => { + setSending(false); + setMessageToUser({ message: i18n.t("Message sent successfully"), type: "success" }); + }, + error => { + setSending(false); + setMessageToUser({ message: error, type: "error" }); + } + ); + }, [toMessageRecipients, subject, text, compositionRoot.comunications]); + + const send = useCallback(async () => { + if (type === "Email") { + sendEmail(); + } else { + sendMessage(); + } + }, [type, sendEmail, sendMessage]); return { - to, + type, + toEmail, + toMessageRecipients, subject, text, messageToUser, sending, attachingFiles, - onToChange, - onSubjectChange, - onTextChange, - onSendEmail, + changeType, + changeToEmail, + changeToMessageRecipients, + changeSubject: onSubjectChange, + changeText, + send, + typeOptions, }; } From 2613fe5c83ee2394aa9ee04592506dc78d73cbdd Mon Sep 17 00:00:00 2001 From: xurxodev Date: Mon, 10 Mar 2025 15:16:21 +0100 Subject: [PATCH 10/15] Refactor Email and Message to ValueObjects --- i18n/en.pot | 11 ++- i18n/es.po | 9 ++- i18n/fr.po | 9 ++- i18n/pt.po | 9 ++- .../comunications/EmailD2ApiRepository.ts | 11 ++- .../comunications/MessageD2ApiRepository.ts | 11 +-- src/domain/common/entities/Validations.ts | 4 + src/domain/common/entities/ValueObject.ts | 15 ++++ src/domain/comunications/entities/Email.ts | 53 +++++++++++++- .../comunications/entities/EmailAddress.ts | 41 +++++++++++ src/domain/comunications/entities/Message.ts | 59 ++++++++++++--- .../entities/MessageRecipient.ts | 48 ++++++++++++ .../repositories/MessageRepository.ts | 3 +- .../SearchMessageRecipientsUseCase.ts | 2 +- .../components/email-input/useEmailInput.ts | 10 +-- .../message-recipients/MessageRecipients.tsx | 6 +- .../useMessageRecipients.ts | 11 +-- .../share-sync-error/useShareSyncError.ts | 73 +++++++++++-------- 18 files changed, 309 insertions(+), 76 deletions(-) create mode 100644 src/domain/common/entities/ValueObject.ts create mode 100644 src/domain/comunications/entities/EmailAddress.ts create mode 100644 src/domain/comunications/entities/MessageRecipient.ts diff --git a/i18n/en.pot b/i18n/en.pot index b7e2300a1..3b664e7a9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" -"PO-Revision-Date: 2025-03-09T16:18:49.808Z\n" +"POT-Creation-Date: 2025-03-10T14:03:49.733Z\n" +"PO-Revision-Date: 2025-03-10T14:03:49.733Z\n" msgid "Unknown error to send email" msgstr "" @@ -1003,7 +1003,7 @@ msgstr "" msgid "Scheduler" msgstr "" -msgid "Sending email" +msgid "Sending" msgstr "" msgid "Share error information" @@ -1024,7 +1024,10 @@ msgstr "" msgid "Email" msgstr "" -msgid "Email sending successfully" +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" msgstr "" msgid "The token is empty" diff --git a/i18n/es.po b/i18n/es.po index 5151afe1e..768c76ac2 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" +"POT-Creation-Date: 2025-03-10T14:03:49.733Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1005,7 +1005,7 @@ msgstr "" msgid "Scheduler" msgstr "" -msgid "Sending email" +msgid "Sending" msgstr "" msgid "Share error information" @@ -1026,7 +1026,10 @@ msgstr "" msgid "Email" msgstr "" -msgid "Email sending successfully" +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" msgstr "" msgid "The token is empty" diff --git a/i18n/fr.po b/i18n/fr.po index fd9d22802..2feab20d3 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" +"POT-Creation-Date: 2025-03-10T14:03:49.733Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1004,7 +1004,7 @@ msgstr "" msgid "Scheduler" msgstr "" -msgid "Sending email" +msgid "Sending" msgstr "" msgid "Share error information" @@ -1025,7 +1025,10 @@ msgstr "" msgid "Email" msgstr "" -msgid "Email sending successfully" +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" msgstr "" msgid "The token is empty" diff --git a/i18n/pt.po b/i18n/pt.po index fd9d22802..2feab20d3 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-03-09T16:18:49.808Z\n" +"POT-Creation-Date: 2025-03-10T14:03:49.733Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1004,7 +1004,7 @@ msgstr "" msgid "Scheduler" msgstr "" -msgid "Sending email" +msgid "Sending" msgstr "" msgid "Share error information" @@ -1025,7 +1025,10 @@ msgstr "" msgid "Email" msgstr "" -msgid "Email sending successfully" +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" msgstr "" msgid "The token is empty" diff --git a/src/data/comunications/EmailD2ApiRepository.ts b/src/data/comunications/EmailD2ApiRepository.ts index 253af37cc..53946ca12 100644 --- a/src/data/comunications/EmailD2ApiRepository.ts +++ b/src/data/comunications/EmailD2ApiRepository.ts @@ -3,11 +3,20 @@ import { EmailRepository } from "../../domain/comunications/repositories/EmailRe import { D2Api } from "../../types/d2-api"; import { apiToFuture } from "../common/utils/futures"; import { FutureData } from "../../domain/common/entities/Future"; +import { OutboundMessage } from "@eyeseetea/d2-api/api/email"; export class EmailD2ApiRepository implements EmailRepository { constructor(private readonly d2Api: D2Api) {} send(message: Email): FutureData { - return apiToFuture(this.d2Api.email.sendMessage(message)); + return apiToFuture(this.d2Api.email.sendMessage(this.buildOutboundMessage(message))); + } + + buildOutboundMessage(message: Email): OutboundMessage { + return { + subject: message.subject, + text: message.text, + recipients: message.recipients.map(recipient => recipient.value), + }; } } diff --git a/src/data/comunications/MessageD2ApiRepository.ts b/src/data/comunications/MessageD2ApiRepository.ts index 45d786c17..1fbbf503b 100644 --- a/src/data/comunications/MessageD2ApiRepository.ts +++ b/src/data/comunications/MessageD2ApiRepository.ts @@ -2,8 +2,9 @@ import { D2Api, MetadataPick } from "../../types/d2-api"; import { apiToFuture } from "../common/utils/futures"; import { Future, FutureData } from "../../domain/common/entities/Future"; import { MessageRepository } from "../../domain/comunications/repositories/MessageRepository"; -import { Message, MessageRecipient } from "../../domain/comunications/entities/Message"; +import { Message } from "../../domain/comunications/entities/Message"; import { PostMessage } from "@eyeseetea/d2-api/api/messageConversations"; +import { MessageRecipient } from "../../domain/comunications/entities/MessageRecipient"; export class MessageD2ApiRepository implements MessageRepository { constructor(private readonly d2Api: D2Api) {} @@ -46,19 +47,19 @@ export class MessageD2ApiRepository implements MessageRepository { } buildUserRecipient(user: D2User): MessageRecipient { - return { + return MessageRecipient.create({ type: "User", id: user.id, name: user.displayName, - }; + }).getOrThrow(); } buildUserGroupRecipient(userGroup: D2UserGroup): MessageRecipient { - return { + return MessageRecipient.create({ type: "UserGroup", id: userGroup.id, name: userGroup.displayName, - }; + }).getOrThrow(); } } diff --git a/src/domain/common/entities/Validations.ts b/src/domain/common/entities/Validations.ts index 2d0ecc22b..199aa0d42 100644 --- a/src/domain/common/entities/Validations.ts +++ b/src/domain/common/entities/Validations.ts @@ -62,3 +62,7 @@ export function validateModel(item: T, validations: ModelValidation[]): Valid return acc; }, [] as ValidationError[]); } + +export function isBlank(value: any): boolean { + return !value || (value.length !== undefined && value.length === 0); +} diff --git a/src/domain/common/entities/ValueObject.ts b/src/domain/common/entities/ValueObject.ts new file mode 100644 index 000000000..51adccd96 --- /dev/null +++ b/src/domain/common/entities/ValueObject.ts @@ -0,0 +1,15 @@ +export abstract class ValueObject { + constructor(protected props: T) {} + + equals(vo?: ValueObject): boolean { + if (vo === null || vo === undefined) { + return false; + } + + if (vo.props === undefined) { + return false; + } + + return JSON.stringify(this.props) === JSON.stringify(vo.props); + } +} diff --git a/src/domain/comunications/entities/Email.ts b/src/domain/comunications/entities/Email.ts index 56157ea3f..f3a7a52b7 100644 --- a/src/domain/comunications/entities/Email.ts +++ b/src/domain/comunications/entities/Email.ts @@ -1,9 +1,56 @@ -import { Struct } from "../../common/entities/Struct"; +import _ from "lodash"; +import { Either } from "../../common/entities/Either"; +import { ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; +import { EmailAddress } from "./EmailAddress"; -export type EmailProps = { +export type EmailData = { recipients: string[]; subject: string; text: string; }; -export class Email extends Struct() {} +export type EmailProps = Pick & { + recipients: EmailAddress[]; +}; + +export class Email extends ValueObject { + public readonly recipients: EmailAddress[]; + public readonly subject: string; + public readonly text: string; + + private constructor(props: EmailProps) { + super(props); + + this.recipients = props.recipients; + this.subject = props.subject; + this.text = props.text; + } + + public static create(data: EmailData): Either { + const recipientsError = data.recipients.length === 0; + + if (recipientsError) + return Either.error([ + { + property: "recipients", + error: "invalid_recipients", + description: "Email must have at least one recipient", + }, + ]); + + const emailResponses = data.recipients.map(email => EmailAddress.create(email)); + + const emailErrors = _.compact( + emailResponses.filter(response => response.isError).map(response => response.value.error) + ); + + if (emailErrors.length > 0) { + return Either.error(emailErrors); + } else { + const emails = _.compact(emailResponses.map(response => response.value.data)); + + return Either.success(new Email({ ...data, recipients: emails })); + } + } +} diff --git a/src/domain/comunications/entities/EmailAddress.ts b/src/domain/comunications/entities/EmailAddress.ts new file mode 100644 index 000000000..50f913322 --- /dev/null +++ b/src/domain/comunications/entities/EmailAddress.ts @@ -0,0 +1,41 @@ +import { Either } from "../../common/entities/Either"; +import { ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; + +export interface EmailAddressProps { + value: string; +} + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export class EmailAddress extends ValueObject { + public readonly value: string; + + private constructor(props: EmailAddressProps) { + super(props); + + this.value = props.value; + } + + public static create(email: string): Either { + const error = this.validate(email); + + if (error) { + return Either.error(error); + } else { + return Either.success(new EmailAddress({ value: this.format(email) })); + } + } + + private static validate(email: string): ValidationError | undefined { + const isValid = EMAIL_PATTERN.test(email); + + return isValid + ? undefined + : { property: "value", error: "invalid_email", description: "Email must be a valid email address" }; + } + + private static format(email: string): string { + return email.trim().toLowerCase(); + } +} diff --git a/src/domain/comunications/entities/Message.ts b/src/domain/comunications/entities/Message.ts index 5a5befdc3..bbe555d19 100644 --- a/src/domain/comunications/entities/Message.ts +++ b/src/domain/comunications/entities/Message.ts @@ -1,15 +1,56 @@ -import { Struct } from "../../common/entities/Struct"; +import _ from "lodash"; +import { Either } from "../../common/entities/Either"; +import { ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; +import { MessageRecipient, MessageRecipientProps } from "./MessageRecipient"; -export type MessageRecipient = { - id: string; - name: string; - type: "User" | "UserGroup"; -}; - -export type MessageProps = { +export type MessageData = { + recipients: MessageRecipientProps[]; subject: string; text: string; +}; + +export type MessageProps = Pick & { recipients: MessageRecipient[]; }; -export class Message extends Struct() {} +export class Message extends ValueObject { + public readonly recipients: MessageRecipient[]; + public readonly subject: string; + public readonly text: string; + + private constructor(props: MessageProps) { + super(props); + + this.recipients = props.recipients; + this.subject = props.subject; + this.text = props.text; + } + + public static create(data: MessageData): Either { + const recipientsError = data.recipients.length === 0; + + if (recipientsError) + return Either.error([ + { + property: "recipients", + error: "invalid_recipients", + description: "Message must have at least one recipient", + }, + ]); + + const recipientResponses = data.recipients.map(recipient => MessageRecipient.create(recipient)); + + const recipientErrors = _.compact( + recipientResponses.filter(response => response.isError).map(response => response.value.error) + ).flat(); + + if (recipientErrors.length > 0) { + return Either.error(recipientErrors); + } else { + const recipients = _.compact(recipientResponses.map(response => response.value.data)); + + return Either.success(new Message({ ...data, recipients: recipients })); + } + } +} diff --git a/src/domain/comunications/entities/MessageRecipient.ts b/src/domain/comunications/entities/MessageRecipient.ts new file mode 100644 index 000000000..98ae87812 --- /dev/null +++ b/src/domain/comunications/entities/MessageRecipient.ts @@ -0,0 +1,48 @@ +import _ from "lodash"; +import { Either } from "../../common/entities/Either"; +import { isBlank, ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; + +export interface MessageRecipientProps { + id: string; + name: string; + type: "User" | "UserGroup"; +} + +export class MessageRecipient extends ValueObject { + public readonly id: string; + public readonly name: string; + public readonly type: "User" | "UserGroup"; + + private constructor(props: MessageRecipientProps) { + super(props); + + this.id = props.id; + this.name = props.name; + this.type = props.type; + } + + public static create(data: MessageRecipientProps): Either { + const error = this.validate(data); + + if (error) { + return Either.error(error); + } else { + return Either.success(new MessageRecipient(data)); + } + } + + private static validate(data: MessageRecipientProps): ValidationError[] | undefined { + const isMissingId = isBlank(data.id); + const isMissingName = isBlank(data.name); + const isMissingType = isBlank(data.type); + + const errors = _.compact([ + isMissingId ? { property: "id", error: "missing_id", description: "Id is required" } : undefined, + isMissingName ? { property: "name", error: "missing_name", description: "Name is required" } : undefined, + isMissingType ? { property: "type", error: "missing_type", description: "Type is required" } : undefined, + ]); + + return errors.length > 0 ? errors : undefined; + } +} diff --git a/src/domain/comunications/repositories/MessageRepository.ts b/src/domain/comunications/repositories/MessageRepository.ts index 24e639b9d..cc5ce3ce2 100644 --- a/src/domain/comunications/repositories/MessageRepository.ts +++ b/src/domain/comunications/repositories/MessageRepository.ts @@ -1,5 +1,6 @@ import { FutureData } from "../../common/entities/Future"; -import { Message, MessageRecipient } from "../entities/Message"; +import { Message } from "../entities/Message"; +import { MessageRecipient } from "../entities/MessageRecipient"; export interface MessageRepository { searchRecipients(text: string): FutureData; diff --git a/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts b/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts index b82b6d769..3f80342ed 100644 --- a/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts +++ b/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../common/entities/Future"; -import { MessageRecipient } from "../entities/Message"; +import { MessageRecipient } from "../entities/MessageRecipient"; import { MessageRepository } from "../repositories/MessageRepository"; export class SearchMessageRecipientsUseCase { diff --git a/src/presentation/react/core/components/email-input/useEmailInput.ts b/src/presentation/react/core/components/email-input/useEmailInput.ts index cd87e2d38..af6cecfc2 100644 --- a/src/presentation/react/core/components/email-input/useEmailInput.ts +++ b/src/presentation/react/core/components/email-input/useEmailInput.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect } from "react"; +import { EmailAddress } from "../../../../../domain/comunications/entities/EmailAddress"; export function useEmailInput( emails?: string[], @@ -19,7 +20,9 @@ export function useEmailInput( const tryAddEmail = useCallback( (email: string) => { - if (isValidEmail(email.trim())) { + const emailAddress = EmailAddress.create(email); + + if (emailAddress.isSuccess()) { const emails = [...internalEmails, email.trim()]; setInternalEmails(emails); setInternalText(""); @@ -67,8 +70,3 @@ export function useEmailInput( handleInternalEmailInputChange, }; } - -// TODO: This is a temp function. This logic should be in domain -function isValidEmail(email: string) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} diff --git a/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx b/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx index c7b71de12..2207903d3 100644 --- a/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx +++ b/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx @@ -1,13 +1,13 @@ import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; import styled from "styled-components"; import { TextField, Chip, TextFieldProps, Menu, MenuItem } from "@material-ui/core"; -import { MessageRecipient } from "../../../../../domain/comunications/entities/Message"; import { useMessageRecipients } from "./useMessageRecipients"; +import { MessageRecipientProps } from "../../../../../domain/comunications/entities/MessageRecipient"; interface MessageRecipientsProps extends Omit { - recipients?: MessageRecipient[]; + recipients?: MessageRecipientProps[]; text?: string; - onRecipientsChange?: (recipients: MessageRecipient[]) => void; + onRecipientsChange?: (recipients: MessageRecipientProps[]) => void; onTextChange?: (text: string) => void; } diff --git a/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts b/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts index 72c182ee0..50f31cbeb 100644 --- a/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts +++ b/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts @@ -1,17 +1,18 @@ import { useState, useCallback, useEffect } from "react"; -import { MessageRecipient } from "../../../../../domain/comunications/entities/Message"; +import { MessageRecipientProps } from "../../../../../domain/comunications/entities/MessageRecipient"; + import { useAppContext } from "../../contexts/AppContext"; export function useMessageRecipients( - recipients?: MessageRecipient[], + recipients?: MessageRecipientProps[], text?: string, - onRecipientsChange?: (recipients: MessageRecipient[]) => void, + onRecipientsChange?: (recipients: MessageRecipientProps[]) => void, onTextChange?: (text: string) => void ) { const { compositionRoot } = useAppContext(); const [internalText, setInternalText] = useState(text || ""); - const [internalRecipients, setInternalRecipients] = useState(recipients || []); - const [recipientCandidates, setRecipientCandidates] = useState([]); + const [internalRecipients, setInternalRecipients] = useState(recipients || []); + const [recipientCandidates, setRecipientCandidates] = useState([]); useEffect(() => { setInternalRecipients(recipients || []); diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index b926cf4c4..10932acc2 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -3,7 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { Future } from "../../../../../domain/common/entities/Future"; import { AttachedFile } from "../../../../../domain/comunications/entities/AttachedFile"; import { Email } from "../../../../../domain/comunications/entities/Email"; -import { Message, MessageRecipient } from "../../../../../domain/comunications/entities/Message"; +import { Message } from "../../../../../domain/comunications/entities/Message"; +import { MessageRecipientProps } from "../../../../../domain/comunications/entities/MessageRecipient"; import { ResultInstance, SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; import { Store } from "../../../../../domain/stores/entities/Store"; import { useAppContext } from "../../contexts/AppContext"; @@ -11,7 +12,7 @@ import { useAppContext } from "../../contexts/AppContext"; type ShareSyncState = { type: ShareSyncType; toEmail: string[]; - toMessageRecipients: MessageRecipient[]; + toMessageRecipients: MessageRecipientProps[]; subject: string; text: string; messageToUser: MessageToUser | undefined; @@ -19,7 +20,7 @@ type ShareSyncState = { attachingFiles: boolean; changeType: (type: ShareSyncType) => void; changeToEmail: (to: string[]) => void; - changeToMessageRecipients: (to: MessageRecipient[]) => void; + changeToMessageRecipients: (to: MessageRecipientProps[]) => void; changeSubject: (subject: string) => void; changeText: (message: string) => void; send: () => void; @@ -43,7 +44,7 @@ type MessageToUser = { export function useShareSyncError(errorResults: SynchronizationResult[]): ShareSyncState { const [type, setType] = useState("Email"); const [toEmail, setToEmail] = useState([]); - const [toMessageRecipients, setToMessageRecipients] = useState([]); + const [toMessageRecipients, setToMessageRecipients] = useState([]); const [subject, setSubject] = useState("Error encountered when trying a migration in MetaData Sync"); const [text, setText] = useState(""); const [messageToUser, setMessageToUser] = useState(); @@ -110,7 +111,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS setToEmail(to); }, []); - const changeToMessageRecipients = useCallback((recipients: MessageRecipient[]) => { + const changeToMessageRecipients = useCallback((recipients: MessageRecipientProps[]) => { setToMessageRecipients(recipients); }, []); @@ -123,45 +124,59 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS }, []); const sendEmail = useCallback(async () => { - const email = Email.create({ + const emailValidation = Email.create({ recipients: toEmail, subject, text, }); - setSending(true); - - compositionRoot.comunications.sendEmail(email).run( - () => { - setSending(false); - setMessageToUser({ message: i18n.t("Email sent successfully"), type: "success" }); + emailValidation.match({ + success: email => { + setSending(true); + + compositionRoot.comunications.sendEmail(email).run( + () => { + setSending(false); + setMessageToUser({ message: i18n.t("Email sent successfully"), type: "success" }); + }, + error => { + setSending(false); + setMessageToUser({ message: error, type: "error" }); + } + ); }, - error => { - setSending(false); - setMessageToUser({ message: error, type: "error" }); - } - ); + error: errors => { + setMessageToUser({ message: errors.join("\n"), type: "error" }); + }, + }); }, [toEmail, subject, text, compositionRoot.comunications]); const sendMessage = useCallback(async () => { - const message = Message.create({ + const messageValidation = Message.create({ recipients: toMessageRecipients, subject, text, }); - setSending(true); - - compositionRoot.comunications.sendMessage(message).run( - () => { - setSending(false); - setMessageToUser({ message: i18n.t("Message sent successfully"), type: "success" }); + messageValidation.match({ + success: message => { + setSending(true); + + compositionRoot.comunications.sendMessage(message).run( + () => { + setSending(false); + setMessageToUser({ message: i18n.t("Message sent successfully"), type: "success" }); + }, + error => { + setSending(false); + setMessageToUser({ message: error, type: "error" }); + } + ); }, - error => { - setSending(false); - setMessageToUser({ message: error, type: "error" }); - } - ); + error: errors => { + setMessageToUser({ message: errors.join("\n"), type: "error" }); + }, + }); }, [toMessageRecipients, subject, text, compositionRoot.comunications]); const send = useCallback(async () => { From 0cf4eeb8efff15698b4db526eff10ec5af094fb3 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Thu, 27 Mar 2025 11:43:22 +0100 Subject: [PATCH 11/15] Show correctly errors --- .../react/core/components/share-sync-error/useShareSyncError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index 10932acc2..af18aed6b 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -146,7 +146,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS ); }, error: errors => { - setMessageToUser({ message: errors.join("\n"), type: "error" }); + setMessageToUser({ message: errors.map(error => error.description).join("\n"), type: "error" }); }, }); }, [toEmail, subject, text, compositionRoot.comunications]); From d5a3031854de733c3eafea15e4d10565106d17aa Mon Sep 17 00:00:00 2001 From: xurxodev Date: Wed, 16 Apr 2025 08:07:27 +0200 Subject: [PATCH 12/15] Remove unused import --- src/data/common/utils/api-futures.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/data/common/utils/api-futures.ts b/src/data/common/utils/api-futures.ts index 164796474..1cf7777d3 100644 --- a/src/data/common/utils/api-futures.ts +++ b/src/data/common/utils/api-futures.ts @@ -1,4 +1,3 @@ -import { AxiosError } from "axios"; import { Future, FutureData } from "../../../domain/common/entities/Future"; import { CancelableResponse } from "../../../types/d2-api"; From 2fe70acf29d3338b209548ba961f102aa6bc246d Mon Sep 17 00:00:00 2001 From: xurxodev Date: Fri, 20 Jun 2025 07:31:21 +0200 Subject: [PATCH 13/15] Add privacy policy agreement check --- i18n/en.pot | 19 ++++++++++-- i18n/es.po | 17 ++++++++++- i18n/fr.po | 17 ++++++++++- i18n/pt.po | 17 ++++++++++- .../share-sync-error/ShareSyncError.tsx | 29 ++++++++++++++++++- .../share-sync-error/useShareSyncError.ts | 9 ++++++ 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8af75d0da..f4e1c3754 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-06-19T10:35:27.012Z\n" -"PO-Revision-Date: 2025-06-19T10:35:27.012Z\n" +"POT-Creation-Date: 2025-06-20T05:24:41.367Z\n" +"PO-Revision-Date: 2025-06-20T05:24:41.367Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -1018,6 +1018,21 @@ msgstr "" msgid "Message" msgstr "" +msgid "I have read and accept the" +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid "paying special attention to the " +msgstr "" + +msgid "Share sesitive data" +msgstr "" + +msgid "section" +msgstr "" + msgid "Email" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 2dce7047b..2bd2912ef 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-02T07:06:35.778Z\n" +"POT-Creation-Date: 2025-06-20T05:22:59.986Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1019,6 +1019,21 @@ msgstr "" msgid "Message" msgstr "" +msgid "I have read and accept the" +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid "paying special attention to the " +msgstr "" + +msgid "Share sesitive data" +msgstr "" + +msgid "section" +msgstr "" + msgid "Email" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 04cec92c4..c83c9f6cc 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-02T07:06:35.778Z\n" +"POT-Creation-Date: 2025-06-20T05:22:59.986Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1019,6 +1019,21 @@ msgstr "" msgid "Message" msgstr "" +msgid "I have read and accept the" +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid "paying special attention to the " +msgstr "" + +msgid "Share sesitive data" +msgstr "" + +msgid "section" +msgstr "" + msgid "Email" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 04cec92c4..c83c9f6cc 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-02T07:06:35.778Z\n" +"POT-Creation-Date: 2025-06-20T05:22:59.986Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1019,6 +1019,21 @@ msgstr "" msgid "Message" msgstr "" +msgid "I have read and accept the" +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid "paying special attention to the " +msgstr "" + +msgid "Share sesitive data" +msgstr "" + +msgid "section" +msgstr "" + msgid "Email" msgstr "" diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx index 5f798fc6c..fb902ca21 100644 --- a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -1,5 +1,5 @@ import { ConfirmationDialog, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; -import { DialogContent, TextField } from "@material-ui/core"; +import { Checkbox, DialogContent, FormControlLabel, Link, TextField } from "@material-ui/core"; import { SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; import i18n from "../../../../../locales"; import React, { useEffect } from "react"; @@ -59,6 +59,10 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { state.changeType(type as ShareSyncType); }; + const handleAgreementChange = (event: React.ChangeEvent) => { + state.onAggreementChange(event.target.checked); + }; + return ( { saveText={i18n.t("Send")} maxWidth={"lg"} fullWidth={true} + disableSave={!state.agreementAccepted} > @@ -109,6 +114,28 @@ export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { multiline rows={12} /> + + } + label={ + <> + {i18n.t("I have read and accept the ")} + + {i18n.t("EyeSeeTea S.L. Privacy Policy")} + + {i18n.t(" paying special attention to the section about ")} + + {i18n.t("share sesitive data")} + + + } + /> diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index 982f4141d..e48b6078e 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -25,6 +25,8 @@ type ShareSyncState = { changeText: (message: string) => void; send: () => void; typeOptions: TypeOption[]; + agreementAccepted: boolean; + onAggreementChange: (accepted: boolean) => void; }; const typeOptions: TypeOption[] = [ @@ -51,6 +53,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS const [sending, setSending] = useState(false); const [attachingFiles, setAttachingFiles] = useState(false); const [attachedFiles, setAttachedFiles] = useState([]); + const [agreementAccepted, setAgreementAccepted] = useState(false); const { compositionRoot } = useAppContext(); @@ -187,6 +190,10 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS } }, [type, sendEmail, sendMessage]); + const onAggreementChange = useCallback((accepted: boolean) => { + setAgreementAccepted(accepted); + }, []); + return { type, toEmail, @@ -203,6 +210,8 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS changeText, send, typeOptions, + agreementAccepted, + onAggreementChange, }; } From 9b02030a30e798a81f4b5ed67455ece24263f52e Mon Sep 17 00:00:00 2001 From: xurxodev Date: Fri, 20 Jun 2025 07:32:26 +0200 Subject: [PATCH 14/15] Update translations --- i18n/en.pot | 13 +++++-------- i18n/es.po | 11 ++++------- i18n/fr.po | 11 ++++------- i18n/pt.po | 11 ++++------- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f4e1c3754..d488d29b2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-06-20T05:24:41.367Z\n" -"PO-Revision-Date: 2025-06-20T05:24:41.367Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" +"PO-Revision-Date: 2025-06-20T05:31:37.220Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -1018,19 +1018,16 @@ msgstr "" msgid "Message" msgstr "" -msgid "I have read and accept the" +msgid "I have read and accept the " msgstr "" msgid "EyeSeeTea S.L. Privacy Policy" msgstr "" -msgid "paying special attention to the " +msgid " paying special attention to the section about " msgstr "" -msgid "Share sesitive data" -msgstr "" - -msgid "section" +msgid "share sesitive data" msgstr "" msgid "Email" diff --git a/i18n/es.po b/i18n/es.po index 2bd2912ef..755561d29 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-20T05:22:59.986Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1019,19 +1019,16 @@ msgstr "" msgid "Message" msgstr "" -msgid "I have read and accept the" +msgid "I have read and accept the " msgstr "" msgid "EyeSeeTea S.L. Privacy Policy" msgstr "" -msgid "paying special attention to the " +msgid " paying special attention to the section about " msgstr "" -msgid "Share sesitive data" -msgstr "" - -msgid "section" +msgid "share sesitive data" msgstr "" msgid "Email" diff --git a/i18n/fr.po b/i18n/fr.po index c83c9f6cc..2c25d9e0c 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-20T05:22:59.986Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1019,19 +1019,16 @@ msgstr "" msgid "Message" msgstr "" -msgid "I have read and accept the" +msgid "I have read and accept the " msgstr "" msgid "EyeSeeTea S.L. Privacy Policy" msgstr "" -msgid "paying special attention to the " +msgid " paying special attention to the section about " msgstr "" -msgid "Share sesitive data" -msgstr "" - -msgid "section" +msgid "share sesitive data" msgstr "" msgid "Email" diff --git a/i18n/pt.po b/i18n/pt.po index c83c9f6cc..2c25d9e0c 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-20T05:22:59.986Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1019,19 +1019,16 @@ msgstr "" msgid "Message" msgstr "" -msgid "I have read and accept the" +msgid "I have read and accept the " msgstr "" msgid "EyeSeeTea S.L. Privacy Policy" msgstr "" -msgid "paying special attention to the " +msgid " paying special attention to the section about " msgstr "" -msgid "Share sesitive data" -msgstr "" - -msgid "section" +msgid "share sesitive data" msgstr "" msgid "Email" From 0ecf6acad1795d50cf0de3da8592a0d063201cd5 Mon Sep 17 00:00:00 2001 From: xurxodev Date: Tue, 24 Jun 2025 11:00:55 +0200 Subject: [PATCH 15/15] Create files as private and: - To send email update it to public and external access - To send message update it to access for user or user group recipients only --- i18n/en.pot | 4 +- .../AttachedFileD2ApiRepository.ts | 22 ++++++----- .../comunications/entities/AttachedFile.ts | 12 ++++++ .../repositories/AttachedFileRepository.ts | 3 +- .../usecases/AttachFileUseCase.ts | 2 +- .../usecases/SendEmailUseCase.ts | 23 +++++++++-- .../usecases/SendMessageUseCase.ts | 39 +++++++++++++++++-- src/presentation/CompositionRoot.ts | 7 ++-- .../share-sync-error/useShareSyncError.ts | 10 +++-- 9 files changed, 94 insertions(+), 28 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 96be22049..59789fa1b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-06-23T08:15:08.886Z\n" -"PO-Revision-Date: 2025-06-23T08:15:08.886Z\n" +"POT-Creation-Date: 2025-06-24T06:49:54.606Z\n" +"PO-Revision-Date: 2025-06-24T06:49:54.606Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " diff --git a/src/data/comunications/AttachedFileD2ApiRepository.ts b/src/data/comunications/AttachedFileD2ApiRepository.ts index 1792a7e60..21c0604fa 100644 --- a/src/data/comunications/AttachedFileD2ApiRepository.ts +++ b/src/data/comunications/AttachedFileD2ApiRepository.ts @@ -1,29 +1,33 @@ import { Future, FutureData } from "../../domain/common/entities/Future"; -import { AttachedFile, AttachedFileInput } from "../../domain/comunications/entities/AttachedFile"; +import { AttachedFile, AttachedFileInput, ObjectSharing } from "../../domain/comunications/entities/AttachedFile"; import { AttachedFileRepository } from "../../domain/comunications/repositories/AttachedFileRepository"; + import { D2Api } from "../../types/d2-api"; import { apiToFuture } from "../common/utils/api-futures"; export class AttachedFileD2ApiRepository implements AttachedFileRepository { constructor(private api: D2Api) {} - save(file: AttachedFileInput): FutureData { + create(file: AttachedFileInput): FutureData { return apiToFuture(this.api.files.upload(file)) .flatMap(({ id }) => { return Future.joinObj({ id: Future.success(id), - sharing: apiToFuture( - this.api.sharing.post( - { id, type: "document" }, - { publicAccess: "rw------", externalAccess: true } - ) - ), + sharing: this.postSharing(id, file.sharing), }); }) .map(({ id }) => { const isDev = process.env.NODE_ENV === "development"; const baseUrl = isDev ? process.env.REACT_APP_DHIS2_BASE_URL : this.api.baseUrl; - return { id, name: file.name, url: `${baseUrl}/api/documents/${id}/data` }; + return { id, name: file.name, url: `${baseUrl}/api/documents/${id}/data`, sharing: file.sharing }; }); } + + updateSharing(file: AttachedFile): FutureData { + return this.postSharing(file.id, file.sharing); + } + + private postSharing(id: string, sharing: ObjectSharing) { + return apiToFuture(this.api.sharing.post({ id, type: "document" }, sharing)).map(() => undefined); + } } diff --git a/src/domain/comunications/entities/AttachedFile.ts b/src/domain/comunications/entities/AttachedFile.ts index ed35e733b..9100d045d 100644 --- a/src/domain/comunications/entities/AttachedFile.ts +++ b/src/domain/comunications/entities/AttachedFile.ts @@ -1,10 +1,22 @@ +import { PartialBy } from "../../../types/utils"; +import { SharingSetting } from "../../common/entities/SharingSetting"; + export type AttachedFile = { id: string; name: string; url: string; + sharing: ObjectSharing; }; export interface AttachedFileInput { name: string; data: Blob; + sharing: ObjectSharing; } + +export type ObjectSharing = { + publicAccess?: string; + externalAccess?: boolean; + userAccesses?: PartialBy[]; + userGroupAccesses?: PartialBy[]; +}; diff --git a/src/domain/comunications/repositories/AttachedFileRepository.ts b/src/domain/comunications/repositories/AttachedFileRepository.ts index 88387aa4c..cc7388748 100644 --- a/src/domain/comunications/repositories/AttachedFileRepository.ts +++ b/src/domain/comunications/repositories/AttachedFileRepository.ts @@ -2,5 +2,6 @@ import { FutureData } from "../../common/entities/Future"; import { AttachedFile, AttachedFileInput } from "../entities/AttachedFile"; export interface AttachedFileRepository { - save(file: AttachedFileInput): FutureData; + create(file: AttachedFileInput): FutureData; + updateSharing(file: AttachedFile): FutureData; } diff --git a/src/domain/comunications/usecases/AttachFileUseCase.ts b/src/domain/comunications/usecases/AttachFileUseCase.ts index 765e6b028..de58e4aca 100644 --- a/src/domain/comunications/usecases/AttachFileUseCase.ts +++ b/src/domain/comunications/usecases/AttachFileUseCase.ts @@ -6,6 +6,6 @@ export class AttachFileUseCase { constructor(private repository: AttachedFileRepository) {} execute(file: AttachedFileInput): FutureData { - return this.repository.save(file); + return this.repository.create(file); } } diff --git a/src/domain/comunications/usecases/SendEmailUseCase.ts b/src/domain/comunications/usecases/SendEmailUseCase.ts index f662d366d..1dd285a4f 100644 --- a/src/domain/comunications/usecases/SendEmailUseCase.ts +++ b/src/domain/comunications/usecases/SendEmailUseCase.ts @@ -1,11 +1,26 @@ -import { FutureData } from "../../common/entities/Future"; +import { Future, FutureData } from "../../common/entities/Future"; +import { AttachedFile } from "../entities/AttachedFile"; import { Email } from "../entities/Email"; +import { AttachedFileRepository } from "../repositories/AttachedFileRepository"; import { EmailRepository } from "../repositories/EmailRepository"; export class SendEmailUseCase { - constructor(private emailRepository: EmailRepository) {} + constructor(private emailRepository: EmailRepository, private attacchedFileRepository: AttachedFileRepository) {} - execute(email: Email): FutureData { - return this.emailRepository.send(email); + execute(email: Email, attachedFiles: AttachedFile[]): FutureData { + return this.updateSharingInFiles(attachedFiles).flatMap(() => { + return this.emailRepository.send(email); + }); + } + private updateSharingInFiles(attachedFiles: AttachedFile[]): FutureData { + const files = attachedFiles.map(file => { + return { ...file, sharing: { publicAccess: "rw------", externalAccess: true } }; + }); + + const futures = files.map(file => { + return this.attacchedFileRepository.updateSharing(file); + }); + + return Future.parallel(futures, { concurrency: 5 }).map(() => undefined); } } diff --git a/src/domain/comunications/usecases/SendMessageUseCase.ts b/src/domain/comunications/usecases/SendMessageUseCase.ts index 4a60c31d5..d82d9a50b 100644 --- a/src/domain/comunications/usecases/SendMessageUseCase.ts +++ b/src/domain/comunications/usecases/SendMessageUseCase.ts @@ -1,11 +1,42 @@ -import { FutureData } from "../../common/entities/Future"; +import { Future, FutureData } from "../../common/entities/Future"; +import { AttachedFile } from "../entities/AttachedFile"; import { Message } from "../entities/Message"; +import { AttachedFileRepository } from "../repositories/AttachedFileRepository"; import { MessageRepository } from "../repositories/MessageRepository"; export class SendMessageUseCase { - constructor(private messageRepository: MessageRepository) {} + constructor( + private messageRepository: MessageRepository, + private attacchedFileRepository: AttachedFileRepository + ) {} - execute(message: Message): FutureData { - return this.messageRepository.send(message); + execute(message: Message, attachedFiles: AttachedFile[]): FutureData { + return this.updateSharingInFiles(message, attachedFiles).flatMap(() => { + return this.messageRepository.send(message); + }); + } + + private updateSharingInFiles(message: Message, attachedFiles: AttachedFile[]): FutureData { + const userAccesses = message.recipients + .filter(recipient => recipient.type === "User") + .map(recipient => { + return { id: recipient.id, name: recipient.name, access: "rw------" }; + }); + + const userGroupAccesses = message.recipients + .filter(recipient => recipient.type === "UserGroup") + .map(recipient => { + return { id: recipient.id, name: recipient.name, access: "rw------" }; + }); + + const files = attachedFiles.map(file => { + return { ...file, sharing: { ...file.sharing, userAccesses, userGroupAccesses } }; + }); + + const futures = files.map(file => { + return this.attacchedFileRepository.updateSharing(file); + }); + + return Future.parallel(futures, { concurrency: 5 }).map(() => undefined); } } diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index 103548e63..4d3c574d4 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -477,11 +477,12 @@ export class CompositionRoot { @cache() public get comunications() { const messageRepository = new MessageD2ApiRepository(this.api); + const attachedFileRepository = new AttachedFileD2ApiRepository(this.api); return getExecute({ - sendEmail: new SendEmailUseCase(new EmailD2ApiRepository(this.api)), - sendMessage: new SendMessageUseCase(messageRepository), + sendEmail: new SendEmailUseCase(new EmailD2ApiRepository(this.api), attachedFileRepository), + sendMessage: new SendMessageUseCase(messageRepository, attachedFileRepository), searchMessageRecipients: new SearchMessageRecipientsUseCase(messageRepository), - attachFile: new AttachFileUseCase(new AttachedFileD2ApiRepository(this.api)), + attachFile: new AttachFileUseCase(attachedFileRepository), }); } diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts index e48b6078e..d0f832733 100644 --- a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -84,10 +84,12 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS compositionRoot.comunications.attachFile({ name: `${result.type}-payload.json`, data: createJsonBobByObject(result.payload), + sharing: { publicAccess: "--------", externalAccess: false }, }), compositionRoot.comunications.attachFile({ name: `${result.type}-summary.json`, data: createJsonBobByObject(result.response), + sharing: { publicAccess: "--------", externalAccess: false }, }), ]; }) @@ -137,7 +139,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS success: email => { setSending(true); - compositionRoot.comunications.sendEmail(email).run( + compositionRoot.comunications.sendEmail(email, attachedFiles).run( () => { setSending(false); setMessageToUser({ message: i18n.t("Email sent successfully"), type: "success" }); @@ -152,7 +154,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS setMessageToUser({ message: errors.map(error => error.description).join("\n"), type: "error" }); }, }); - }, [toEmail, subject, text, compositionRoot.comunications]); + }, [toEmail, subject, text, compositionRoot.comunications, attachedFiles]); const sendMessage = useCallback(async () => { const messageValidation = Message.create({ @@ -165,7 +167,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS success: message => { setSending(true); - compositionRoot.comunications.sendMessage(message).run( + compositionRoot.comunications.sendMessage(message, attachedFiles).run( () => { setSending(false); setMessageToUser({ message: i18n.t("Message sent successfully"), type: "success" }); @@ -180,7 +182,7 @@ export function useShareSyncError(errorResults: SynchronizationResult[]): ShareS setMessageToUser({ message: errors.join("\n"), type: "error" }); }, }); - }, [toMessageRecipients, subject, text, compositionRoot.comunications]); + }, [toMessageRecipients, subject, text, compositionRoot.comunications, attachedFiles]); const send = useCallback(async () => { if (type === "Email") {