@zeniai/client-epic-state 5.2.32 → 5.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/lib/commonPayloadTypes/classesView/reportByClassPayloadV2.d.ts +13 -1
  2. package/lib/entity/sectionClassesViewV2/sectionClassesViewEpic.js +10 -7
  3. package/lib/entity/sectionClassesViewV2/sectionClassesViewReducer.d.ts +3 -3
  4. package/lib/entity/sectionProjectView/sectionProjectViewEpic.js +6 -2
  5. package/lib/entity/sectionProjectView/sectionProjectViewReducer.d.ts +3 -3
  6. package/lib/entity/sectionProjectView/sectionProjectViewReducer.js +4 -3
  7. package/lib/epic.js +12 -0
  8. package/lib/esm/entity/class/classReducer.js +1 -1
  9. package/lib/esm/entity/sectionClassesViewV2/sectionClassesViewEpic.js +10 -7
  10. package/lib/esm/entity/sectionProjectView/sectionProjectViewEpic.js +6 -2
  11. package/lib/esm/entity/sectionProjectView/sectionProjectViewReducer.js +5 -4
  12. package/lib/esm/epic.js +12 -0
  13. package/lib/esm/index.js +2 -2
  14. package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +266 -1
  15. package/lib/esm/view/aiCfoView/aiCfoViewSelector.js +50 -0
  16. package/lib/esm/view/aiCfoView/epics/deleteRoutineEpic.js +35 -0
  17. package/lib/esm/view/aiCfoView/epics/fetchRoutineRunsEpic.js +35 -0
  18. package/lib/esm/view/aiCfoView/epics/fetchRoutinesEpic.js +37 -0
  19. package/lib/esm/view/aiCfoView/epics/runRoutineEpic.js +34 -0
  20. package/lib/esm/view/aiCfoView/epics/saveRoutineEpic.js +65 -0
  21. package/lib/esm/view/aiCfoView/epics/setRoutineEnabledEpic.js +28 -0
  22. package/lib/esm/view/aiCfoView/epics/toRoutine.js +64 -0
  23. package/lib/esm/view/aiCfoView/epics/toRoutineRun.js +42 -0
  24. package/lib/esm/view/expenseAutomationView/selectors/fluxAnalysisViewSelector.js +12 -10
  25. package/lib/esm/view/expenseAutomationView/selectors/jeSchedulesViewSelector.js +3 -11
  26. package/lib/esm/view/expenseAutomationView/selectors/missingReceiptsSelector.js +10 -11
  27. package/lib/index.d.ts +3 -3
  28. package/lib/index.js +55 -40
  29. package/lib/view/aiCfoView/aiCfoViewPayload.d.ts +90 -0
  30. package/lib/view/aiCfoView/aiCfoViewReducer.d.ts +164 -123
  31. package/lib/view/aiCfoView/aiCfoViewReducer.js +268 -3
  32. package/lib/view/aiCfoView/aiCfoViewSelector.d.ts +13 -1
  33. package/lib/view/aiCfoView/aiCfoViewSelector.js +55 -0
  34. package/lib/view/aiCfoView/aiCfoViewState.d.ts +157 -0
  35. package/lib/view/aiCfoView/epics/deleteRoutineEpic.d.ts +18 -0
  36. package/lib/view/aiCfoView/epics/deleteRoutineEpic.js +39 -0
  37. package/lib/view/aiCfoView/epics/fetchRoutineRunsEpic.d.ts +13 -0
  38. package/lib/view/aiCfoView/epics/fetchRoutineRunsEpic.js +39 -0
  39. package/lib/view/aiCfoView/epics/fetchRoutinesEpic.d.ts +7 -0
  40. package/lib/view/aiCfoView/epics/fetchRoutinesEpic.js +41 -0
  41. package/lib/view/aiCfoView/epics/runRoutineEpic.d.ts +14 -0
  42. package/lib/view/aiCfoView/epics/runRoutineEpic.js +38 -0
  43. package/lib/view/aiCfoView/epics/saveRoutineEpic.d.ts +13 -0
  44. package/lib/view/aiCfoView/epics/saveRoutineEpic.js +69 -0
  45. package/lib/view/aiCfoView/epics/setRoutineEnabledEpic.d.ts +15 -0
  46. package/lib/view/aiCfoView/epics/setRoutineEnabledEpic.js +32 -0
  47. package/lib/view/aiCfoView/epics/toRoutine.d.ts +17 -0
  48. package/lib/view/aiCfoView/epics/toRoutine.js +69 -0
  49. package/lib/view/aiCfoView/epics/toRoutineRun.d.ts +12 -0
  50. package/lib/view/aiCfoView/epics/toRoutineRun.js +47 -0
  51. package/lib/view/cashFlowClassesView/cashFlowClassesViewPayload.d.ts +4 -4
  52. package/lib/view/cashFlowProjectView/cashFlowProjectViewPayload.d.ts +4 -4
  53. package/lib/view/expenseAutomationView/selectors/fluxAnalysisViewSelector.js +12 -10
  54. package/lib/view/expenseAutomationView/selectors/jeSchedulesViewSelector.js +3 -11
  55. package/lib/view/expenseAutomationView/selectors/missingReceiptsSelector.js +10 -11
  56. package/lib/view/profitAndLossProjectView/profitAndLossProjectViewPayload.d.ts +12 -0
  57. package/package.json +1 -1
@@ -81,6 +81,29 @@ export const initialAiCfoViewState = {
81
81
  skillsEnabled: false,
82
82
  canPublish: false,
83
83
  },
84
+ routines: {
85
+ fetchState: 'Not-Started',
86
+ error: undefined,
87
+ deletedScheduleIds: [],
88
+ routines: [],
89
+ routinesEnabled: false,
90
+ // 0 until the server says otherwise. The form reads this to decide whether
91
+ // "New routine" is available, and a guessed cap would either block a user
92
+ // who is under it or invite one who is over it to fill in a doomed form.
93
+ maxRoutines: 0,
94
+ },
95
+ routineMutation: {
96
+ fetchState: 'Not-Started',
97
+ error: undefined,
98
+ kind: undefined,
99
+ },
100
+ routineRuns: {
101
+ fetchState: 'Not-Started',
102
+ error: undefined,
103
+ runs: [],
104
+ scheduleId: null,
105
+ sessionId: null,
106
+ },
84
107
  };
85
108
  /**
86
109
  * Forget the skill that was open — prompt AND history together.
@@ -263,6 +286,15 @@ const aiCfoView = createSlice({
263
286
  fetchState: 'Error',
264
287
  error,
265
288
  };
289
+ // A page that failed is not a promise of another one. Leaving `hasMore`
290
+ // at its default meant the screen kept asking: the fetch condition is
291
+ // "not in flight and there is more", and Error satisfies the first half,
292
+ // so a session whose history 404s was requested in a tight loop forever
293
+ // while the view sat on its loading skeletons.
294
+ draft.uiState.chatHistoryNextPageToken[chatSessionId] = {
295
+ nextPageToken: null,
296
+ hasMore: false,
297
+ };
266
298
  },
267
299
  setSession(draft, action) {
268
300
  draft.uiState.currentChatSessionId = action.payload.sessionId;
@@ -601,6 +633,229 @@ const aiCfoView = createSlice({
601
633
  error,
602
634
  };
603
635
  },
636
+ fetchRoutines(draft) {
637
+ draft.routines = {
638
+ fetchState: 'In-Progress',
639
+ error: undefined,
640
+ routines: draft.routines.routines,
641
+ maxRoutines: draft.routines.maxRoutines,
642
+ // Carried for the same reason as maxRoutines: a refetch in flight is not
643
+ // a reason to hide the entry the user is currently looking at.
644
+ routinesEnabled: draft.routines.routinesEnabled,
645
+ // Carried, not cleared: the response about to arrive may still contain a
646
+ // routine deleted moments ago, which is the whole reason the list exists.
647
+ deletedScheduleIds: draft.routines.deletedScheduleIds,
648
+ };
649
+ },
650
+ fetchRoutinesSuccess(draft, action) {
651
+ draft.routines = {
652
+ fetchState: 'Completed',
653
+ error: undefined,
654
+ // A list response can predate a delete — a save refetches, and a delete
655
+ // can land while that is outstanding — so replacing wholesale would put
656
+ // the deleted row back and read as the delete having failed.
657
+ routines: action.payload.routines.filter((routine) => !draft.routines.deletedScheduleIds.includes(routine.scheduleId)),
658
+ maxRoutines: action.payload.maxRoutines,
659
+ routinesEnabled: action.payload.routinesEnabled,
660
+ // Carried, NOT cleared here.
661
+ //
662
+ // The skills slice clears its tombstone on a list response, which fixed
663
+ // one bug and introduced the mirror of it: two list responses can both
664
+ // land before the DELETE is processed, and the second one — arriving
665
+ // with an empty tombstone — puts the deleted row back. The window is
666
+ // small and entirely real, since a save refetches.
667
+ //
668
+ // The tombstone's true lifetime is the delete request, so it is dropped
669
+ // in deleteRoutineSuccess / deleteRoutineFailure instead. That bounds it
670
+ // just as tightly (a delete always resolves) without this race.
671
+ deletedScheduleIds: draft.routines.deletedScheduleIds,
672
+ };
673
+ },
674
+ fetchRoutinesFailure(draft, action) {
675
+ draft.routines = {
676
+ fetchState: 'Error',
677
+ error: action.payload,
678
+ routines: draft.routines.routines,
679
+ // Kept: a failed refresh is not evidence the cap changed, and zeroing it
680
+ // would disable "New routine" on any transient error.
681
+ maxRoutines: draft.routines.maxRoutines,
682
+ // Same again: a transient error must not make the entry vanish.
683
+ routinesEnabled: draft.routines.routinesEnabled,
684
+ deletedScheduleIds: draft.routines.deletedScheduleIds,
685
+ };
686
+ },
687
+ setRoutineEnabled: {
688
+ prepare: (payload) => ({
689
+ payload,
690
+ }),
691
+ reducer(draft, action) {
692
+ // Pause and resume without a full draft. A routine whose cadence this
693
+ // client does not recognise has `recurrence: null`, so it cannot build
694
+ // a RoutineDraft at all — and that is exactly the routine a user most
695
+ // needs to be able to stop. Flipped locally too, so the switch responds
696
+ // immediately; the refetch confirms it.
697
+ const routine = draft.routines.routines.find((candidate) => candidate.scheduleId === action.payload.scheduleId);
698
+ if (routine) {
699
+ routine.enabled = action.payload.enabled;
700
+ }
701
+ draft.routineMutation = {
702
+ fetchState: 'In-Progress',
703
+ error: undefined,
704
+ kind: 'save',
705
+ };
706
+ },
707
+ },
708
+ fetchRoutineRuns: {
709
+ prepare: (payload) => ({ payload }),
710
+ reducer(draft, action) {
711
+ // Only a different routine's rows are wrong to show. This list polls
712
+ // itself while a run is in flight, and clearing on every fetch blanked
713
+ // the history and its summary a few times a minute — the rows were
714
+ // being replaced by "Loading runs…" and then re-rendered, which reads
715
+ // as the page breaking rather than refreshing.
716
+ const sameRoutine = draft.routineRuns.scheduleId === action.payload.scheduleId;
717
+ draft.routineRuns = {
718
+ fetchState: 'In-Progress',
719
+ error: undefined,
720
+ // Showing one routine's runs under another's name is the one thing a
721
+ // history must never do, so a change of routine still clears.
722
+ runs: sameRoutine ? draft.routineRuns.runs : [],
723
+ scheduleId: action.payload.scheduleId,
724
+ sessionId: sameRoutine ? draft.routineRuns.sessionId : null,
725
+ };
726
+ },
727
+ },
728
+ fetchRoutineRunsSuccess: {
729
+ prepare: (payload) => ({ payload }),
730
+ reducer(draft, action) {
731
+ // A response for a routine the user has since collapsed or swapped away
732
+ // from is dropped. switchMap makes this rare, not impossible.
733
+ if (draft.routineRuns.scheduleId !== action.payload.scheduleId) {
734
+ return;
735
+ }
736
+ draft.routineRuns = {
737
+ fetchState: 'Completed',
738
+ error: undefined,
739
+ runs: action.payload.runs,
740
+ scheduleId: action.payload.scheduleId,
741
+ sessionId: action.payload.sessionId,
742
+ };
743
+ },
744
+ },
745
+ fetchRoutineRunsFailure(draft, action) {
746
+ draft.routineRuns.fetchState = 'Error';
747
+ draft.routineRuns.error = action.payload;
748
+ },
749
+ closeRoutineRuns(draft) {
750
+ draft.routineRuns = {
751
+ fetchState: 'Not-Started',
752
+ error: undefined,
753
+ runs: [],
754
+ scheduleId: null,
755
+ sessionId: null,
756
+ };
757
+ },
758
+ runRoutine: {
759
+ prepare: (payload) => ({ payload }),
760
+ reducer(draft, action) {
761
+ draft.routineMutation = {
762
+ fetchState: 'In-Progress',
763
+ error: undefined,
764
+ kind: 'run',
765
+ runningScheduleId: action.payload.scheduleId,
766
+ };
767
+ },
768
+ },
769
+ runRoutineSuccess(draft) {
770
+ draft.routineMutation = {
771
+ fetchState: 'Completed',
772
+ error: undefined,
773
+ kind: 'run',
774
+ // Cleared: the request is done. The RUN is not — the answer arrives by
775
+ // email a minute or two later, and the row reports that through
776
+ // last_run_at on the next fetch, not through this flag.
777
+ runningScheduleId: undefined,
778
+ };
779
+ },
780
+ runRoutineFailure(draft, action) {
781
+ draft.routineMutation = {
782
+ fetchState: 'Error',
783
+ error: action.payload,
784
+ kind: 'run',
785
+ runningScheduleId: undefined,
786
+ };
787
+ },
788
+ saveRoutine: {
789
+ prepare: (payload) => ({
790
+ payload,
791
+ }),
792
+ reducer(draft) {
793
+ draft.routineMutation = {
794
+ fetchState: 'In-Progress',
795
+ error: undefined,
796
+ kind: 'save',
797
+ };
798
+ },
799
+ },
800
+ saveRoutineSuccess(draft) {
801
+ draft.routineMutation = {
802
+ fetchState: 'Completed',
803
+ error: undefined,
804
+ kind: 'save',
805
+ };
806
+ },
807
+ saveRoutineFailure(draft, action) {
808
+ // On its OWN slice, so the `fetchRoutines` this write triggers cannot
809
+ // clear the error in the same tick and report a failed save as nothing.
810
+ draft.routineMutation = {
811
+ fetchState: 'Error',
812
+ error: action.payload,
813
+ kind: 'save',
814
+ };
815
+ },
816
+ deleteRoutine: {
817
+ prepare: (payload) => ({ payload }),
818
+ reducer(draft, action) {
819
+ draft.routineMutation = {
820
+ fetchState: 'In-Progress',
821
+ error: undefined,
822
+ kind: 'delete',
823
+ };
824
+ // Removed immediately AND tombstoned. The row must leave the list now —
825
+ // waiting for the refetch makes the click feel ignored — and the
826
+ // tombstone is what stops an in-flight list response restoring it.
827
+ draft.routines.routines = draft.routines.routines.filter((routine) => routine.scheduleId !== action.payload.scheduleId);
828
+ draft.routines.deletedScheduleIds = [
829
+ ...draft.routines.deletedScheduleIds,
830
+ action.payload.scheduleId,
831
+ ];
832
+ },
833
+ },
834
+ deleteRoutineSuccess(draft, action) {
835
+ // The server has it. Nothing it sends from here on will contain this row,
836
+ // so the tombstone has done its job and holding it any longer is what
837
+ // makes a routine recreated under the same id invisible forever.
838
+ draft.routines.deletedScheduleIds = draft.routines.deletedScheduleIds.filter((id) => id !== action.payload.scheduleId);
839
+ draft.routineMutation = {
840
+ fetchState: 'Completed',
841
+ error: undefined,
842
+ kind: 'delete',
843
+ };
844
+ },
845
+ deleteRoutineFailure(draft, action) {
846
+ // The routine still exists and is still emailing. Dropping the tombstone
847
+ // is what lets the refetch put the row back — leaving it hidden would
848
+ // tell the user they had stopped something they had not, which is the
849
+ // worst outcome this list can produce.
850
+ draft.routines.deletedScheduleIds = draft.routines.deletedScheduleIds.filter((id) => id !== action.payload.scheduleId);
851
+ // Its own slice: the refetch that puts the row back would otherwise wipe
852
+ // this error, leaving the row silently reappearing with no explanation.
853
+ draft.routineMutation = {
854
+ fetchState: 'Error',
855
+ error: action.payload.status,
856
+ kind: 'delete',
857
+ };
858
+ },
604
859
  fetchSkills(draft) {
605
860
  // Read straight off `draft.skills` — no optional chain. This store is never
606
861
  // persisted or rehydrated (no redux-persist anywhere in the repo, and no
@@ -898,8 +1153,18 @@ const aiCfoView = createSlice({
898
1153
  kind: undefined,
899
1154
  };
900
1155
  },
1156
+ openRoutinesBrowser(draft) {
1157
+ draft.uiState.isRoutinesBrowserOpen = true;
1158
+ // The two browsers share the page body, so opening one closes the other.
1159
+ // Leaving both true renders whichever the screen happens to check first.
1160
+ draft.uiState.isSkillsBrowserOpen = false;
1161
+ },
1162
+ closeRoutinesBrowser(draft) {
1163
+ draft.uiState.isRoutinesBrowserOpen = false;
1164
+ },
901
1165
  openSkillsBrowser(draft) {
902
1166
  draft.uiState.isSkillsBrowserOpen = true;
1167
+ draft.uiState.isRoutinesBrowserOpen = false;
903
1168
  },
904
1169
  closeSkillsBrowser(draft) {
905
1170
  // Unguarded, unlike the implicit closes: an explicit close should clear a lingering
@@ -918,5 +1183,5 @@ const aiCfoView = createSlice({
918
1183
  });
919
1184
  },
920
1185
  });
921
- export const { submitQuestion, submitQuestionSuccess, submitQuestionFailure, updateResponseState, updateAiCfoViewScrollPosition, updateCurrentInput, submitFeedback, submitFeedbackSuccess, submitFeedbackFailure, setSession, createSession, createSessionSuccess, createSessionFailure, createSessionAndSubmit, initializeNewSessionState, createSessionAndSubmitSuccess, createSessionAndSubmitFailure, fetchChatSessionsForUser, fetchChatSessionsForUserSuccess, fetchChatSessionsForUserFailure, fetchChatHistory, fetchChatHistorySuccess, fetchChatHistoryFailure, clearCurrentSessionId, clearLastContextMessage, stopSubmit, stopSubmitSuccess, stopSubmitFailure, stopSubmitQuestion, stopSubmitQuestionSuccess, stopSubmitQuestionFailure, acceptMasterTOS, acceptMasterTOSSuccess, acceptMasterTOSFailure, deleteChatSession, deleteChatSessionSuccess, deleteChatSessionFailure, clearDeleteChatSessionStatus, updateCotCollapsedState, fetchSuggestedQuestions, fetchSuggestedQuestionsSuccess, fetchSuggestedQuestionsFailure, fetchSkills, fetchSkillsSuccess, fetchSkillsFailure, fetchSkillDetail, fetchSkillDetailSuccess, fetchSkillDetailFailure, clearSkillDetail, clearSkillMutation, deleteSkill, deleteSkillSuccess, deleteSkillFailure, fetchSkillVersionBody, fetchSkillVersionBodySuccess, fetchSkillVersionBodyFailure, fetchSkillVersions, fetchSkillVersionsSuccess, fetchSkillVersionsFailure, saveSkill, saveSkillSuccess, saveSkillFailure, setSkillVisibility, setSkillVisibilitySuccess, setSkillVisibilityFailure, openSkillsBrowser, closeSkillsBrowser, applyAiCfoSidePanelHostPageTransition, clearAiCfoSidePanelHostPageContext, } = aiCfoView.actions;
1186
+ export const { acceptMasterTOS, acceptMasterTOSFailure, acceptMasterTOSSuccess, applyAiCfoSidePanelHostPageTransition, clearAiCfoSidePanelHostPageContext, clearCurrentSessionId, clearDeleteChatSessionStatus, clearLastContextMessage, clearSkillDetail, clearSkillMutation, closeRoutineRuns, closeRoutinesBrowser, closeSkillsBrowser, createSession, createSessionAndSubmit, createSessionAndSubmitFailure, createSessionAndSubmitSuccess, createSessionFailure, createSessionSuccess, deleteChatSession, deleteChatSessionFailure, deleteChatSessionSuccess, deleteRoutine, deleteRoutineFailure, deleteRoutineSuccess, deleteSkill, deleteSkillFailure, deleteSkillSuccess, fetchChatHistory, fetchChatHistoryFailure, fetchChatHistorySuccess, fetchChatSessionsForUser, fetchChatSessionsForUserFailure, fetchChatSessionsForUserSuccess, fetchRoutineRuns, fetchRoutineRunsFailure, fetchRoutineRunsSuccess, fetchRoutines, fetchRoutinesFailure, fetchRoutinesSuccess, fetchSkillDetail, fetchSkillDetailFailure, fetchSkillDetailSuccess, fetchSkills, fetchSkillsFailure, fetchSkillsSuccess, fetchSkillVersionBody, fetchSkillVersionBodyFailure, fetchSkillVersionBodySuccess, fetchSkillVersions, fetchSkillVersionsFailure, fetchSkillVersionsSuccess, fetchSuggestedQuestions, fetchSuggestedQuestionsFailure, fetchSuggestedQuestionsSuccess, initializeNewSessionState, openRoutinesBrowser, openSkillsBrowser, runRoutine, runRoutineFailure, runRoutineSuccess, saveRoutine, saveRoutineFailure, saveRoutineSuccess, saveSkill, saveSkillFailure, saveSkillSuccess, setRoutineEnabled, setSession, setSkillVisibility, setSkillVisibilityFailure, setSkillVisibilitySuccess, stopSubmit, stopSubmitFailure, stopSubmitQuestion, stopSubmitQuestionFailure, stopSubmitQuestionSuccess, stopSubmitSuccess, submitFeedback, submitFeedbackFailure, submitFeedbackSuccess, submitQuestion, submitQuestionFailure, submitQuestionSuccess, updateAiCfoViewScrollPosition, updateCotCollapsedState, updateCurrentInput, updateResponseState, } = aiCfoView.actions;
922
1187
  export default aiCfoView.reducer;
@@ -64,6 +64,56 @@ const EMPTY_SKILL_MUTATION_STATE = {
64
64
  savedMacroId: undefined,
65
65
  kind: undefined,
66
66
  };
67
+ // Stable fallback, same reasoning as EMPTY_SKILLS_STATE: a fresh literal each
68
+ // call defeats useSelector identity checks and re-renders the list for nothing.
69
+ const EMPTY_ROUTINES_STATE = {
70
+ fetchState: 'Not-Started',
71
+ error: undefined,
72
+ routines: [],
73
+ // 0 withholds "New routine" until the server states the cap. Guessing a number
74
+ // either blocks a user who is under it or invites one who is over it into a
75
+ // form that cannot save.
76
+ maxRoutines: 0,
77
+ // false, so the Routines entry stays hidden until the server confirms the
78
+ // feature is on. Defaulting true would flash an entry at tenants without it.
79
+ routinesEnabled: false,
80
+ deletedScheduleIds: [],
81
+ };
82
+ const EMPTY_ROUTINE_MUTATION_STATE = {
83
+ fetchState: 'Not-Started',
84
+ error: undefined,
85
+ kind: undefined,
86
+ };
87
+ export function getRoutineMutation(state) {
88
+ return state.aiCfoViewState?.routineMutation ?? EMPTY_ROUTINE_MUTATION_STATE;
89
+ }
90
+ const EMPTY_ROUTINE_RUNS_STATE = {
91
+ fetchState: 'Not-Started',
92
+ error: undefined,
93
+ runs: [],
94
+ scheduleId: null,
95
+ sessionId: null,
96
+ };
97
+ export function getRoutineRuns(state) {
98
+ return state.aiCfoViewState?.routineRuns ?? EMPTY_ROUTINE_RUNS_STATE;
99
+ }
100
+ export function getRoutines(state) {
101
+ return state.aiCfoViewState?.routines ?? EMPTY_ROUTINES_STATE;
102
+ }
103
+ /**
104
+ * Whether another routine may be created.
105
+ *
106
+ * False while the cap is still unknown: offering the control and failing on
107
+ * save is worse than withholding it for the moment the list takes to load.
108
+ */
109
+ export function canCreateRoutine(state) {
110
+ const { maxRoutines, routines } = getRoutines(state);
111
+ return maxRoutines > 0 && routines.length < maxRoutines;
112
+ }
113
+ /** True only when the server says Routines is on for this tenant. */
114
+ export function getRoutinesEnabled(state) {
115
+ return getRoutines(state).routinesEnabled;
116
+ }
67
117
  export function getSkills(state) {
68
118
  return state.aiCfoViewState?.skills ?? EMPTY_SKILLS_STATE;
69
119
  }
@@ -0,0 +1,35 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessStatus, } from '../../../responsePayload';
4
+ import { deleteRoutine, deleteRoutineFailure, deleteRoutineSuccess, fetchRoutines, } from '../aiCfoViewReducer';
5
+ /**
6
+ * DELETE /1.0/routines/{scheduleId}.
7
+ *
8
+ * The row has already left the list by the time this runs — the reducer removes
9
+ * it optimistically so the click does not feel ignored.
10
+ *
11
+ * A failure therefore has to put it BACK, not merely report. The routine still
12
+ * exists and is still emailing the user their financials on a schedule; leaving
13
+ * it hidden would tell them they had stopped something they had not. So a
14
+ * failure drops the tombstone and refetches, and the error explains why the row
15
+ * returned.
16
+ */
17
+ export const deleteRoutineEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(deleteRoutine.match),
18
+ // mergeMap: deleting one routine must not cancel the delete of another.
19
+ mergeMap((action) => {
20
+ const { scheduleId } = action.payload;
21
+ return zeniAPI
22
+ .deleteAndGetJSON(`${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/routines/${encodeURIComponent(scheduleId)}`)
23
+ .pipe(mergeMap((response) =>
24
+ // isSuccessStatus, NOT isSuccessResponse: the latter also requires a
25
+ // non-null `data`, and a successful DELETE here is a 204 with no body.
26
+ // deleteSkillEpic carries the same note for the same reason — using
27
+ // the wrong one reported every completed delete as a failure, which
28
+ // here would also drop the tombstone and refetch the row back.
29
+ isSuccessStatus(response)
30
+ ? of(deleteRoutineSuccess({ scheduleId }))
31
+ : of(deleteRoutineFailure({ scheduleId, status: response.status }), fetchRoutines())), catchError((error) => of(deleteRoutineFailure({
32
+ scheduleId,
33
+ status: createZeniAPIStatus('Unexpected Error', 'Delete routine REST API call failed', error instanceof Error ? { message: error.message } : undefined),
34
+ }), fetchRoutines())));
35
+ }));
@@ -0,0 +1,35 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, map, switchMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
4
+ import { fetchRoutineRuns, fetchRoutineRunsFailure, fetchRoutineRunsSuccess, } from '../aiCfoViewReducer';
5
+ import { isRoutineRunPayload, toRoutineRun } from './toRoutineRun';
6
+ /**
7
+ * GET /1.0/routines/{scheduleId}/runs — one routine's run history.
8
+ *
9
+ * switchMap: only the history the user has open matters, so expanding a second
10
+ * routine while the first is still loading should abandon the first rather than
11
+ * race it into the single slot the state keeps.
12
+ */
13
+ export const fetchRoutineRunsEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchRoutineRuns.match), switchMap((action) => {
14
+ const { scheduleId } = action.payload;
15
+ return zeniAPI
16
+ .getJSON(`${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/routines/${encodeURIComponent(scheduleId)}/runs`)
17
+ .pipe(map((response) => {
18
+ // The explicit `data == null` is what narrows the type — the helper
19
+ // returns a plain boolean, not a type guard — and it means a
20
+ // malformed 2xx becomes a failure rather than an empty history,
21
+ // which would read as "this has never run" when it has.
22
+ if (!isSuccessResponse(response) || response.data == null) {
23
+ return fetchRoutineRunsFailure(response.status);
24
+ }
25
+ const { data } = response;
26
+ return fetchRoutineRunsSuccess({
27
+ // Filtered, not trusted: one malformed row must not take the
28
+ // whole history down, and a run without an id is not addressable
29
+ // by anything the UI does with it.
30
+ runs: (data.runs ?? []).filter(isRoutineRunPayload).map(toRoutineRun),
31
+ scheduleId,
32
+ sessionId: data.session_id ?? null,
33
+ });
34
+ }), catchError((error) => of(fetchRoutineRunsFailure(createZeniAPIStatus('Unexpected Error', 'Fetch routine runs REST API call failed', error instanceof Error ? { message: error.message } : undefined)))));
35
+ }));
@@ -0,0 +1,37 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap, switchMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
4
+ import { fetchRoutines, fetchRoutinesFailure, fetchRoutinesSuccess, } from '../aiCfoViewReducer';
5
+ import { isRoutinePayload, toRoutine } from './toRoutine';
6
+ /** GET /1.0/routines — the caller's own scheduled runs. */
7
+ export const fetchRoutinesEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchRoutines.match),
8
+ // switchMap: a read. A newer list request makes an older one's answer
9
+ // irrelevant, and letting both land means the older can arrive second and
10
+ // overwrite the newer — which matters here because every write refetches,
11
+ // so two GETs in flight is the normal case rather than the exotic one. It
12
+ // also widens the delete tombstone's race window.
13
+ switchMap(() => zeniAPI
14
+ .getJSON(`${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/routines`)
15
+ .pipe(mergeMap((response) => {
16
+ // The explicit `data == null` is what narrows the type — the
17
+ // helper returns a plain boolean, not a type guard — and it means a
18
+ // malformed 2xx becomes a failure rather than a silent empty list,
19
+ // which would read as "you have no routines" when you do.
20
+ if (!isSuccessResponse(response) || response.data == null) {
21
+ return of(fetchRoutinesFailure(response.status));
22
+ }
23
+ const { data } = response;
24
+ return of(fetchRoutinesSuccess({
25
+ // Malformed rows are dropped, not defaulted into existence: a
26
+ // routine with no id cannot be opened, paused or deleted, so
27
+ // rendering one offers controls that cannot work.
28
+ routines: (data.routines ?? [])
29
+ .filter(isRoutinePayload)
30
+ .map(toRoutine),
31
+ maxRoutines: data.max_routines ?? 0,
32
+ // Absent reads as OFF, matching the server's fail-closed gate:
33
+ // an older backend that does not send it should hide the entry,
34
+ // not show one whose endpoints 404.
35
+ routinesEnabled: data.routines_enabled === true,
36
+ }));
37
+ }), catchError((error) => of(fetchRoutinesFailure(createZeniAPIStatus('Unexpected Error', 'Fetch routines REST API call failed', error instanceof Error ? { message: error.message } : undefined)))))));
@@ -0,0 +1,34 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessStatus } from '../../../responsePayload';
4
+ import { fetchRoutineRuns, fetchRoutines, runRoutine, runRoutineFailure, runRoutineSuccess, } from '../aiCfoViewReducer';
5
+ /**
6
+ * POST /1.0/routines/{scheduleId}/run — run a routine now.
7
+ *
8
+ * The response says the run was QUEUED, not that it finished: the answer is
9
+ * emailed a minute or two later. So nothing is spliced in — both refetches are
10
+ * what show the new run, and the outcome arrives on a later fetch rather than
11
+ * being polled for.
12
+ */
13
+ export const runRoutineEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(runRoutine.match),
14
+ // mergeMap, not exhaustMap: these target different rows, so running one
15
+ // routine must not swallow a request to run another. A double-click on the
16
+ // SAME row is already harmless — the server keys a manual run by the
17
+ // minute, so the second press finds the first run and reports it.
18
+ mergeMap((action) => {
19
+ const { scheduleId } = action.payload;
20
+ return zeniAPI
21
+ .postAndGetJSON(`${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/routines/${encodeURIComponent(scheduleId)}/run`, {})
22
+ .pipe(mergeMap((response) =>
23
+ // isSuccessStatus, not isSuccessResponse: "already_running" is a
24
+ // legitimate 200 whose body carries no routine, and treating a
25
+ // second press as a failure would be a lie about what happened.
26
+ isSuccessStatus(response)
27
+ ? of(runRoutineSuccess(), fetchRoutines(),
28
+ // The run history too, or the panel the user is looking at
29
+ // keeps showing the runs from before they pressed Run. The
30
+ // new row comes back as claimed/running — the run has been
31
+ // queued, not finished — which is the honest thing to show.
32
+ fetchRoutineRuns({ scheduleId }))
33
+ : of(runRoutineFailure(response.status))), catchError((error) => of(runRoutineFailure(createZeniAPIStatus('Unexpected Error', 'Run routine REST API call failed', error instanceof Error ? { message: error.message } : undefined)))));
34
+ }));
@@ -0,0 +1,65 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, exhaustMap, filter, mergeMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
4
+ import { fetchRoutines, saveRoutine, saveRoutineFailure, saveRoutineSuccess, } from '../aiCfoViewReducer';
5
+ /**
6
+ * POST /1.0/routines (create) or PUT /1.0/routines/{scheduleId} (update).
7
+ *
8
+ * One epic for both: to a user they are the same act, and the server answers
9
+ * both with the routine. Splitting them would duplicate the body mapping and
10
+ * the error handling for a difference the form does not have.
11
+ */
12
+ export const saveRoutineEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(saveRoutine.match),
13
+ // exhaustMap, and neither of the obvious two.
14
+ //
15
+ // switchMap is wrong: it cancels the client's subscription, not the request
16
+ // that already left the browser. The routine is still created; the client
17
+ // just stops listening and never tells the user.
18
+ //
19
+ // mergeMap is wrong for the same reason in reverse: a double-clicked Save
20
+ // sends two POSTs, and the user gets two routines that both email them the
21
+ // company's financials on the same schedule.
22
+ //
23
+ // exhaustMap ignores further saves while one is in flight, which is exactly
24
+ // what a submit button should do.
25
+ exhaustMap((action) => {
26
+ const { draft, scheduleId } = action.payload;
27
+ const base = `${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/routines`;
28
+ const isUpdate = scheduleId != null && scheduleId !== '';
29
+ const body = {
30
+ name: draft.name,
31
+ recurrence: draft.recurrence,
32
+ hour: draft.hour,
33
+ minute: draft.minute ?? 0,
34
+ tz_name: draft.tzName,
35
+ // Spread rather than sent as null. `weekday` and `day_of_month` are
36
+ // meaningful only for their own recurrence, and the server validates
37
+ // that pairing — sending a stale weekday alongside a monthly cadence is
38
+ // a 400 rather than a field politely ignored.
39
+ ...(draft.recurrence === 'weekly' &&
40
+ draft.weekday != null && { weekday: draft.weekday }),
41
+ ...(draft.recurrence === 'monthly' &&
42
+ draft.dayOfMonth != null && { day_of_month: draft.dayOfMonth }),
43
+ ...(draft.enabled != null && { enabled: draft.enabled }),
44
+ // `macro_id` is create-only: re-sending it on an update would let an
45
+ // edit silently repoint a schedule at a different skill.
46
+ ...(!isUpdate && draft.macroId != null && { macro_id: draft.macroId }),
47
+ // `prompt` is allowed on both. On update the server accepts it only for
48
+ // a one-off, where it saves a new version of the routine's own
49
+ // instructions — that is editing what this routine says, not repointing
50
+ // it at someone else's skill.
51
+ ...(draft.prompt != null && { prompt: draft.prompt }),
52
+ };
53
+ const request$ = isUpdate
54
+ ? zeniAPI.putAndGetJSON(`${base}/${encodeURIComponent(scheduleId)}`, body)
55
+ : zeniAPI.postAndGetJSON(base, body);
56
+ return request$.pipe(mergeMap((response) => {
57
+ if (!isSuccessResponse(response)) {
58
+ return of(saveRoutineFailure(response.status));
59
+ }
60
+ // Refetch rather than splice the response in: a create changes the
61
+ // list's ordering, and the server recomputes `next_run_at` on every
62
+ // write, so a local insert would show a time the next load moves.
63
+ return of(saveRoutineSuccess(), fetchRoutines());
64
+ }), catchError((error) => of(saveRoutineFailure(createZeniAPIStatus('Unexpected Error', 'Save routine REST API call failed', error instanceof Error ? { message: error.message } : undefined)))));
65
+ }));
@@ -0,0 +1,28 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
4
+ import { fetchRoutines, saveRoutineFailure, saveRoutineSuccess, setRoutineEnabled, } from '../aiCfoViewReducer';
5
+ /**
6
+ * PUT /1.0/routines/{scheduleId} with nothing but `enabled`.
7
+ *
8
+ * Separate from `saveRoutineEpic` because pausing is not editing. A routine
9
+ * whose cadence this client version does not recognise has `recurrence: null`
10
+ * and cannot produce a `RoutineDraft` — and that is precisely the routine
11
+ * someone most needs to be able to stop. Sending only `enabled` also means a
12
+ * pause can never rewrite a schedule as a side effect.
13
+ */
14
+ export const setRoutineEnabledEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(setRoutineEnabled.match),
15
+ // mergeMap: pausing one routine must not cancel pausing another, and these
16
+ // target different rows so they cannot collide the way two saves of one
17
+ // form would.
18
+ mergeMap((action) => {
19
+ const { enabled, scheduleId } = action.payload;
20
+ return zeniAPI
21
+ .putAndGetJSON(`${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/routines/${encodeURIComponent(scheduleId)}`, { enabled })
22
+ .pipe(mergeMap((response) => isSuccessResponse(response)
23
+ ? of(saveRoutineSuccess(), fetchRoutines())
24
+ : // Refetch on failure too: the switch was flipped optimistically,
25
+ // and leaving it showing "paused" for a routine that is still
26
+ // running would be the one lie this list must not tell.
27
+ of(saveRoutineFailure(response.status), fetchRoutines())), catchError((error) => of(saveRoutineFailure(createZeniAPIStatus('Unexpected Error', 'Set routine enabled REST API call failed', error instanceof Error ? { message: error.message } : undefined)), fetchRoutines())));
28
+ }));