From 1b5fc9c66588e33b7818135af6cb3b9f4e1f04f3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 28 Apr 2025 10:36:14 -0400 Subject: [PATCH] feat: notifications (#17701) * feat: notifications * UI works * chore: pr feedback * initial fetch and clear notification upon logging out * fix: merge --------- Co-authored-by: Alex Tran --- i18n/en.json | 5 + mobile/openapi/README.md | Bin 33711 -> 34964 bytes mobile/openapi/lib/api.dart | Bin 12358 -> 12684 bytes .../lib/api/notifications_admin_api.dart | Bin 4054 -> 5789 bytes mobile/openapi/lib/api/notifications_api.dart | Bin 0 -> 9840 bytes mobile/openapi/lib/api_client.dart | Bin 31476 -> 32146 bytes mobile/openapi/lib/api_helper.dart | Bin 6600 -> 6822 bytes .../lib/model/notification_create_dto.dart | Bin 0 -> 5756 bytes .../model/notification_delete_all_dto.dart | Bin 0 -> 3066 bytes .../openapi/lib/model/notification_dto.dart | Bin 0 -> 5719 bytes .../openapi/lib/model/notification_level.dart | Bin 0 -> 2966 bytes .../openapi/lib/model/notification_type.dart | Bin 0 -> 3023 bytes .../model/notification_update_all_dto.dart | Bin 0 -> 3393 bytes .../lib/model/notification_update_dto.dart | Bin 0 -> 3031 bytes mobile/openapi/lib/model/permission.dart | Bin 15069 -> 15823 bytes open-api/immich-openapi-specs.json | 555 ++++++++++++++++-- open-api/typescript-sdk/src/fetch-client.ts | 194 ++++-- server/src/controllers/index.ts | 2 + .../notification-admin.controller.ts | 20 +- .../controllers/notification.controller.ts | 60 ++ server/src/database.ts | 1 + server/src/db.d.ts | 18 + server/src/dtos/notification.dto.ts | 108 +++- server/src/enum.ts | 20 + server/src/queries/access.repository.sql | 9 + .../src/queries/notification.repository.sql | 58 ++ server/src/repositories/access.repository.ts | 22 + server/src/repositories/event.repository.ts | 3 + server/src/repositories/index.ts | 4 +- .../repositories/notification.repository.ts | 103 ++++ server/src/schema/index.ts | 2 + .../1744991379464-AddNotificationsTable.ts | 22 + .../src/schema/tables/notification.table.ts | 52 ++ server/src/services/backup.service.spec.ts | 27 +- server/src/services/backup.service.ts | 2 +- server/src/services/base.service.ts | 2 + server/src/services/index.ts | 2 + server/src/services/job.service.ts | 6 +- .../notification-admin.service.spec.ts | 111 ++++ .../services/notification-admin.service.ts | 120 ++++ .../src/services/notification.service.spec.ts | 77 --- server/src/services/notification.service.ts | 93 ++- server/src/types.ts | 4 + server/src/utils/access.ts | 6 + server/test/medium.factory.ts | 24 +- .../notification.controller.spec.ts | 86 +++ .../repositories/access.repository.mock.ts | 4 + server/test/small.factory.ts | 1 + server/test/utils.ts | 4 + .../navigation-bar/navigation-bar.svelte | 27 +- .../navigation-bar/notification-item.svelte | 114 ++++ .../navigation-bar/notification-panel.svelte | 82 +++ .../lib/stores/notification-manager.svelte.ts | 38 ++ web/src/lib/stores/websocket.ts | 5 +- web/src/routes/auth/login/+page.svelte | 6 +- 55 files changed, 1909 insertions(+), 190 deletions(-) create mode 100644 mobile/openapi/lib/api/notifications_api.dart create mode 100644 mobile/openapi/lib/model/notification_create_dto.dart create mode 100644 mobile/openapi/lib/model/notification_delete_all_dto.dart create mode 100644 mobile/openapi/lib/model/notification_dto.dart create mode 100644 mobile/openapi/lib/model/notification_level.dart create mode 100644 mobile/openapi/lib/model/notification_type.dart create mode 100644 mobile/openapi/lib/model/notification_update_all_dto.dart create mode 100644 mobile/openapi/lib/model/notification_update_dto.dart create mode 100644 server/src/controllers/notification.controller.ts create mode 100644 server/src/queries/notification.repository.sql create mode 100644 server/src/repositories/notification.repository.ts create mode 100644 server/src/schema/migrations/1744991379464-AddNotificationsTable.ts create mode 100644 server/src/schema/tables/notification.table.ts create mode 100644 server/src/services/notification-admin.service.spec.ts create mode 100644 server/src/services/notification-admin.service.ts create mode 100644 server/test/medium/specs/controllers/notification.controller.spec.ts create mode 100644 web/src/lib/components/shared-components/navigation-bar/notification-item.svelte create mode 100644 web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte create mode 100644 web/src/lib/stores/notification-manager.svelte.ts diff --git a/i18n/en.json b/i18n/en.json index eafb3415d..8404d6d1d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -857,6 +857,7 @@ "failed_to_remove_product_key": "Failed to remove product key", "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", + "failed_to_update_notification_status": "Failed to update notification status", "import_path_already_exists": "This import path already exists.", "incorrect_email_or_password": "Incorrect email or password", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", @@ -1199,6 +1200,9 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", + "mark_as_read": "Mark as read", + "mark_all_as_read": "Mark all as read", + "marked_all_as_read": "Marked all as read", "matches": "Matches", "media_type": "Media type", "memories": "Memories", @@ -1260,6 +1264,7 @@ "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", + "no_notifications": "No notifications", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "not_selected": "Not selected", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5a7a42cce59356c3369284567282098ba817cf0d..b8ea4b924c183f1ca137072126a95e874c474582 100644 GIT binary patch delta 1055 zcmZ4A&NO8r(}pnB$x}`Fx&87>GSf1X6H7AlC)cY=m=t8{<)$d7q~@fSq~;;y@-!7{ z6tuKlTzy_}08iLKWk~oZmxH=ZAflw!7F%hqOOAAsGOH$FCYzVOd zB8TbffKaTimVvt(!%&FBjd2+ZbvqVwizk0jRTNAvO2u%bvN1#yi}U=0!R|{;$<53| zNeN)nCa0)o2xF7GuiA!9d~=1Gmt8$l2<170o#RrHAFGj)pRBKsRSuX=HMtaY6{3-4 zUBC&~F((JRW@Kqp{q>MMkcUHGNj|D_sA`|oveXp$xO>kPAtjH&nwnXOv%m6o9v+3hE06) OO-(Pm&C4_2$^if|ogJhA diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d08f9fda38c0a46235f3e0751856acc214080ceb..e845099bd2a92bb65756e1001504e63d3f560171 100644 GIT binary patch delta 156 zcmX?>(38C3AIszrW|7VBS&W2u^72bE(=wA2OEUBGCM$@^iz5rfr=;ejmZZif=HyIn t@FZI;#Kx delta 21 dcmeB4K9;cIAIoM%R%M~d`z6>n=Sj#(0sv$@2e$wK diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart index c58bf8978db28d3fcbc5b703b9bde77772638c5f..409683a950a93533f6589c9c002adcf20b3b4734 100644 GIT binary patch delta 516 zcmca6KUa6dEk@zQl-$fb{k;5=%(Tqp#FEVXyyD6K7(LbekVKq|QWHy3T}tv}6_6DI zg(o*MsktPBrI9s+XO?7ml#~>B=B4FpAWOha!Z7F^Q=k|Yx2RilP5#FuK6yT~3O}0N zn@=#WV2A!{lt{q%)HH8ID)WCNOGNI#j4{gzb?WKI~RhwdT?6YAl=cUQVQ z?zlTUc1Vj;KNvWjv@5N4SKqgCt5s>WpnKXs{`qz9ZSP&T-`j_S!=qjU_K)H1aUXs^ z-akC}^AUEVp1*lancZj2U!GU!QC!GC@!>d7!?Eyr8)kB*xi{oKpK@s$@w1qQfii_7 zm_KI=Jq%}NsQBlZP&iz&DSn1b;kV&Rqj05*hmQiO4U=XlJX9#gf-5^*7b^3GtKcw`A#fr4_D>m#-)w+L72%KW5Po@4r)#i%4yVr>4h9w zbCUkZquLNEKE^$=+dK3)?t)u^Ms=})(8MRqOwgpNTC{H46`-kj=(7>;`hKly4bni> zb!*Eao!0v3BqQcKZO7-I_z1T&477O}c#CMH+ojJlt`;X`G>sG4`6GGUI~%-6O?JYW z$JMH%vq1yg_ukbq-I@0G+OAfxH4fmXL67S%LT+ae2_)_0OAzh%%88mnUIu^h?#W>- zvQT8mM)Vl@QZ;@~EirD2O%Xq*R~NUf77@3kr`i;{Hx11KIv{7hk1>*YYa`x`!<_d@*xLn)h4;3l zdCt6dnEALi^(YPiV^all$^gADBOX%LDH7C(CPV>(a>+MeE)3U6-+DBWWyXSEmpe9= zHqvQe-zEl(#C;PCAf2nyZ*HGnbSA%)X)IOvz4B1??nwr{t;{_4BmY!c82ZtX+4`gd zS^fWIq%udmt?QR%E?q0rQ0hEuuKv|2E*oKGu6f@w-|Jp~OCZw)H|f31XNI3L&*Tn5p}^F z=?!*r7>`)wv!}l{V2=GK48bj?OGJ%?(LfktT2^w`SPo{yl+%#85(fDdwh7&UkHoaw zh>201`G^Woj6;P;`5vE2{v||G!ad+f>2S1=NTDCcq7RTjFq42snA0MRT3Go(JQtM2 zEe6+?*|~{jKr1Z{kvckq1M5$o;L`Je_E84V%HWmbBr81YIA;V`k#0C@-1IowQ!ror z&AY?{YYt$S)MXQ`37L{GuQ1r2vEdKDx{r-6n!lmv&Z{YL`yrw(KEKSbL+UdW#7@nkY}#qB}KWL%=4&pF-*19s#S~x;o&D% zGEyWDfFxSaxuZ}!7UkOSyl8T^J4&XZD44$Ta-FB%QL-D1vfYoBbQ}7ZL$%3N7pPF< zV$l|9?ph|f-^YF~W&BGi=tyX@lh%dldM^(zbnUw)p%UJ6ZC0ebZ4;u6jdq*_$)Vd( z;)@b)lKQr>5cGg*Hic@kPDJImq()c{VH`9`5J?1iuE9hx;3D~$^$X*tN=GcvtGdm3 z1bLbxRuE~mR0k8#h}s3#8reBr-;A;3&CzG?1e{Jz|06DqWnr8!6^HSXBAr=ILysAr zD>Wv~H`N(uc6?f5hPAjOBnOdJ;$`YCCTU$Oz}%BNkF|1SvYKrHQr92y8?8m6pZ_>) zf5Wkpjulrm$j4BsaFC%EiBDx}mr3hd++=fal6Uj!G9gQJrX(u2@XF@lq~6p?eUIu7 z`D1ir^@ntHy0|L3iA%q^{Rx>ZB$R0^2tqfNwJ6`%>G%z`g2JQE&GzB+i~si_zT)4? te+($|!t6EN&3^B$;qpgJ_8KlDCx6%!`uSI%X4&v6m%fFk*|M^%`~w(tpsfG^ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0d8e4c6ba977a320dd4be409c8b5141f864882c5..7586cc1ae2a4f5cf3c059ec026c10af3e1cae3ed 100644 GIT binary patch delta 272 zcmezJm2uK<#tqtbTz>f_nQ58Hi6xnnA3DkNAh`K?lmA<5iXscRq~@fSq&nv0Og<

~2BGV7P@* QRu@~ delta 18 acmbRAoAJw6#tqtbll2{0H=lDjVgmqG7zkSc diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1ebf8314ad41165f0f3c8fdc56aeec0c406024e9..cc517d48ab52740f07c86961b514d9e616a43ec2 100644 GIT binary patch delta 87 zcmX?Myv%gNPi_sr{F2PH%;dz9%=|o`)UwnZO@(SM1t2I&Eh#O^Q-H}&-pK301LJ@c VPcGyU=70%I?&EXc{FD1UF94*3Ai4km delta 12 UcmZ2xdct_aPwvgvdCv0!04VVV{Qv*} diff --git a/mobile/openapi/lib/model/notification_create_dto.dart b/mobile/openapi/lib/model/notification_create_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..07985353b2f273573da9a5afd3183bf786cb1386 GIT binary patch literal 5756 zcmeHL-A@}i5P#2KF(A|g-8QuMaH?nntqMKSqi|~JNhgFNv+Iy;H|w13Es7}r`^|@U z*C89=PP%*Q17U5CXT~$X`LK6(dOJJt=JVO<(_ary4nMv*J3N4QN5_W=9Gt?*=^1=D zJve&z=LV7y41ed!;(xELL*~|XZ%<9>yej=T&ro1K$+hL}u2Wc%m)eaB zQ(hsNpQ>{F;@LQzD(iYZ6z9QAz|3{2=J^o+UiNxvp`3#gL#$Kfb5p*xT6ulo4cznq zcF+nRwXbaHKtZXm;k=Xh0&-R~)kECXNiraQ0DhXgVWzxFqM>%F&F3UOnl1Dr-2yeIX@O(V^h2IOUoQBePbn_YN2y=_3vVH+> zYN{fGsrshjQtJ|l%BN*j6za0j8}KPFQ;m9PaC4nDb_7C9V4fN6h&381EvihTDKkA$ zRpH^ADk=?w%H`!1GuaF)yPJ&$+X+m~HCn?aOmd}5ZP7_tVDC(2QH9hX=UL@EOjJ?8 zrAog7x)Jpz-srucGLU)5P2}9S2r$H!1(jt&)}a9Ez2<-{0QciEnvAR$7$f`B%P%_h z`#{cSYk%?L+&L0WbubW{ zwj5S~DQO-ngM17ai~UZdos%U;TlAa6WdWyJ^CW_s(H0vZ2|yWb$q}(6QuQBg@jo1) z);PdlY|psfkGAChMW0pupysbcGZeYNUMx@dP}%p+l&GpSDU-ZZ1$@CN7{Lnl&FxF} zZax8yED4bf!9&dgc>B5S)8YCT$fxOiWcMxT0y_>7h{#)^CvOJQJK|c~z*?y-O(eXX zd_aemkaw?DMR2-K30`f*^YDJb?T)n~1Kiiil;u`v9SGih_UT}I=*^KcFMfIUyhi8! z3PtShYSeg)k=IJ(p>S;w!r`?}Wm=53-mRC|E~lHNUbdqifE9wM<~M zGeK?QJ%LIgIZ|46ESh+Vnh#)`AEgQ=8K%nlm83C{8wW899_SP}U~xo6d1Du5YW@dZ z5l$y(h>KTJ=~x;E%Y&*)gl%uM2c;NTR+8+qqN9XLq@qN^g91YUwGsmk zZrMG-fpz$C2n94A=ji)R&+b8iYwv0!*02;$-f%Z}Wz98RDzRAL70&78w#J#D%xXt> zMl4C)63#DLv=U$H@JJzSk2v0kxxP0frfbZl<10filr$(5#f^E!!B>=Y=hcpyHI~Im)I$B$?vut z#aX9oYCDL5kScBnx_QOzLG1<9;9e&%-AJ6YE`4B9ZL?(kcr?cM8+@js=O}4TmmG?- zc2T=@NTXc4`bcC~Io2ax%iM-c969O%7o*9}W#d=9pf-s23r<_l)*fE+Gj5G46uF3s z2%F0A+X7tYeu`5RSoF5l>5r#Ei(5mHqBjeCfT8y=WQp(!q5C0f3<+6lf^3g=(RwFd z5_nuFBaawK_sRP=czPsM9s|2jkfJp4Agb_i%RcXP%#hsH7%>7@(nN9_(@BH4j)Z{n zBB1Qx`<-qu(Yyd6{2cb+3cXHmWS5#+4ToYWJ`}wFbb3N>R{;#%e_(!+C)?j>Z*vpa XyJ4I3r7B5H>W7NKkBcvRn0@{R0a!T# literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/notification_delete_all_dto.dart b/mobile/openapi/lib/model/notification_delete_all_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..4be1b89e920c5b846f00b8db8dc28f43b0940b4a GIT binary patch literal 3066 zcmbVOZExC05dO}um{zA?B-h;Cr-~f3(Lqk>wLxlgA5I8a8+(E68Lzv$EW028yT;(iB~FL7 zMt&=mLHAs2@v{&HznwG|gByF^J<+*!B6qP$F`-&1Y1rMKO_r%#+Bnns8qM5Bipx!8gpOTS0YwFH8)YMvS{MU&35#Wj4-Nw?3*e)&?vF3dsC+#EwBnoZ z^>=9uLV*VNT4&UZ5l$NzkAwOBSD9doZX?YA#^gniB_azbRaigy@x%WD^+e+r7hGeU zN?FXll_FDaA0dAtvjJzI%0nv5m)ovPPddOl zH_|H0W&s9`YTw7vJtp9^{}FJj{VuE*7;O9tek}Wp;3rX3sD_a4QHUy;V5|>T=NQQo zvr@Up;49ujY93iX94@&`V2g$6WAOeGEo(mgW5bGJ8@h2IPJc>CAo3LafLTz^!grCC z(!$EI`#aHYheg0NPUepo@KE%B9%B#0T&v=>n^%pBi}R|*on6#kzy}(D1Z}k zI9!y7W>k)(23|Q7tfHbTXTTXbYidWQtYY>X+)&~^qmeuQy@P=C(XcO$c7t|3lVA`z zO|u-Vjowk55v*{C)d96z7D1%IguM zHHaLAN4;XFy>7ZXn7~#PXE~}%_AsFp(csr!*a^s_UQmQUO$XNTBpYlu_H1>H6Rxl7 zKrn*l-5ulW6Hf_r8t~iugju|6_BFQ=T`6hnt_WS<*~>ZRfDzlb7<-0m1a9FS|C4*# z)a>syd!)Fg`h;+FZ3Yh8$4lC-jF$GV+nsV7yhU?Hr;9F<-*|KMt`<1HbkL2CTND+g zc1AHz&o=9ZOwMZOrSpx++p2md<vqZ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/notification_dto.dart b/mobile/openapi/lib/model/notification_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..4f730b4e50deec34b24ee8074486b8f603b9b3bd GIT binary patch literal 5719 zcmeHLZBH9H5dO}u7!WE!mxlH}oGRKtt3prhQ8=~qq!U7s*>#9}n|021iXzH?zh}n# z;!VQQJE`CL0kXDd#^YyR?Y+H?y*+yM>HO^Rug9myA6}gwAJLn)C&zs{I-}FGb9#Sv z^!Cl4n;>J#m$`Cb`19cDvke}a>(V+s8e2CSn?mo?yqtTTk947@x(xk#Y^F2o!debh z+^B^g&E{d`^xt#iz+S2;{?C-dZ_v&|xby4TGh6zg$}loHHZ)_Sov{10n-!+iepJ}< z8f0Oj%F(kYqimv_-`K!*f$W&e@+erk-5}b-`9J6| zccmvqrM{tyC9+F0qFkm2pkG?-u?(X6%oHZZ4>G4!(D|#-mqnS&RZ!_r`^=dcYZ7O> z5=_2sw$NX6kqE&y%_d#`U_znSi3p3C%-`(-6`~{Pd2Xhf8kJ=lpkR@q`MuD}YbtGk zTb$!?G;@H~g$4PEofkPx)E7-xT9+UyAD8o@P*;WCq>rY|H1N>mXF6+eC-$&Pcy6_4 z(EvCr=DCIeay?e_BG4CA%r*7q-jvrOWUE@)-)c12?bF2G029|2l51UR$G}(Oy`4L# zkXcBc&V8VJdb2+6oB6M98fj@Kb8R* zqgM%r__M3eItz!y9`OV1?~V4y;9x(X-3FY19_PvAGD@TRgrkfk)_$1hdz+_DK!-@7 zoXk?&ND^By&pD!0LRD5?T7}btSvb2BL`SV!(lH*8d)SEw@bqVSevU$rivcN zWxrne`ZazLVm0*yt`UzOK{CblHnBr`n=-pJ9M-%nza8$x1%;sX$gTvF^D_Q-xWyrP zA%1k$B6hr3eRWm^M_!B{-8Dv&m+-p+s3`+o>wr}0C$Bk+4i5y3re)wxq12i7UbzKV zdcZr;-gqJFHAQ7mOW38K;!=$gCto_GC$uksZLXnAV1lA4L%Xtu&*ZDJ1iIDf7QRG! zoLnPVP+S!v)uTD*3YJyA2{wN8#2YHbaK*_RyGW2Yooqa2tI? z^pD}ToV8y$#Be(v2ZfS5gW=%pEIrUt=&3R!O#zi+*L_REu}H9S#>8Iy$S zK>TGhn!nbg*?|{Z;XI1mcccq6FHvhaZLLv5&*rX3Mdx*On&Pyq$bNhn7&d zmsM$$HF+zYs}*OXW>JjXuFEizG)JkN`@U1eS9OcxZ@u7zjW}V%ADdg#*+y?_L-i>&Q2>)7t(s7=^uk&hc5*e2#U*o z%sf&{Zj7(`x$5e)kDgSIaT$?&jGJdDKz_ufX@qMHD$WQupoz^nFUE=GI&&y0NSIY& z7gei$eLlN(D%Yq;J<+dETT8iP3mrW}#Dypk{{$DlC%!JYou_#8HV5exP~!!Lol zn=rLOv%(t_N5b3E?j#~i{vanJF4a_mYQJN!6K)X)>@;FAJ}wen6^tow42SjLRG|`) zY0oI(@Su({hSIG#WV%{1q$^6s$Z8oYuelSGR^e4gu7p?RuO|kcvg8K`RABZ^4MXj? z(fYU2j$J!@!}mdCe%!vi?%&n^7`@SH&(}TFk!9Hzm#sguL~hPl33Npg{{!Q}ay*@d zMJ*C8j>fN7Nwzn9KV#N*y$*sHAj6JP5nC-WiJubi zF2c`R5C%7@0`h&*sj&yG1keG~^U7Mk=+n0aC}gA_vwo-l<2T>v65rxpRXIw6|3UA; zvAPysGQ)t)(*Tz^QNM}LHd6bIXRQfy?36gf9v;zjWBcaO4sPbr8Y|eCAF8f2HxwS* zas()P_wnqMACM~3OP7JH6W>{XW8S6_Ke!b%($X6zP}O3Qsp?cUh=rscpkfFWdU#}> Fe*jl#Cffi2 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/notification_level.dart b/mobile/openapi/lib/model/notification_level.dart new file mode 100644 index 0000000000000000000000000000000000000000..554863ae4fb180d6c42d13ba7e906e2392e2910e GIT binary patch literal 2966 zcma)8ZExE)5dQ98aRG`(0X%uzr^2o428gqH$dX}oJ`9E-&=O^HrAUpWVi-mK`|e0d zb}ZRjfhJOTZ+z~#=QJ7(MkBa=UOfHq%j|LX=j~#42lw-b*%0oY;PGhzzdzm0@Bg|& zGqU`WG2i z|ECfL-4!eFXUPoycHCGDZfw7Ms+Dz2xkO}`P~?Ie?{0gONujt+q*fa=b6cj84?ia9 zmKi%3U^)jn2VHW-N)hA#kAuM=mCRar)D$~qPH6SOU$}(t1Aq}iFc0|!a~v2bz5}kR z0(Qb}!EHH|M!#Tf87yCztoSNMKVNh=W}HNBD{SlyZ!metu)rvl%D9nxp*G-8sZg>F z$qHVqxuM9pP)x#CXIe`i=8Jye#$Xv}QXe`U9-`sz>$g>! za>QoRdlPeApI_rm0N!|FE!v#*^QS#tJ7yGJ>s-G2yvI|hT%UELL-MK7eh}8vfJq6Z zwpOeq5AawJDuBME7O$2aQuyY+xv6g!LG-C({CSBP5txAD(7*VrhsXLh;e~3Xr&3q03&WJf=`Fa4ZsLrmI^)q1{pw7FNzVm9;@|I2 zQ~r&-8N&Cq))F>G?-&6%ft*Rphp#t&a2lc-ZY$}|z=M}1fJ6=v#RO!#Z_fMoK<}}z zc&AxWx_uKA!y>PuFa(&I8b+pv^Coc^qCO_I5Td_lF4YjK2u@cMP?eO&F#*CwVwPns ze8CUk)!(A|9o`Igl~IkC({!(>0c+gC=JUS}UP)_5&^F7f5CmaD5p$3-1;|P2-U{$p zcuh(PE{mxnHwAud;5)xID zw*KjKa!xEy(x7&R89t+}=ge!l3*beaApwHHDr zrz%AajN@4hzv>+>Ff*jVI!Wzk^m6Vx_J33mauX|`74H73+E67RO7yg@X3n zNkLfW9&k-(d)U!!FyieWbHI&_8yta0l#X_8m`}Vcjv+2wlmf#G+`cQYnBz;ASNbYb4b%pRmSdiVd_!4c-ZR!)J=|S>7lG^_ZsG;BN literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart new file mode 100644 index 0000000000000000000000000000000000000000..436d2d190fd1e95ab4a6b005b5678992c7b7ff2e GIT binary patch literal 3023 zcmai0ZExa65dO}um`K%yNYu3bbmGc!X+v60cS+Td4;4bzVvk|(>|J+v9fVH#?>Dn+ zJ76FeDPXhnKF>U}!(neYgzLxo{m*|*@23A=&!;zVJNq;pz|B3}-Ou6k{mtz5zY7v$ z$q%V8Zv0#H>)Rf`%C*u4<4l`4lR1u{QkBIt#vBV&ZqU5TqSVHn)DZbr>}*_CE;jgo zB@L-7QP5{841GH^mW&&FT0POqI-y)FQ*tOXiN>qjlg@If(8jq|YZ7xCq2jl{#K}e& z+v|}#2RZ{?q7tQy=>J`>*GqC?E!=6god_qjn(s=4Z#{q^RbUp}OW_a%DBJ?7s(`I@ z8*m$h(&ErrFC$SYh%q~~KkG8x5vw~;pTa#!R!IGB@>N~LJ>om6Y^ryM5B244jh zD>Q64lS<_9;7lv&&3w>LXbeR`BK4uu;h}#860+*Bwz=85gBwwrYb^Sl~WKNi{JL|CcjMj=Zo{QmOU+9F9q_R#I=d}Oy zr>s`@;*+!tO5W1mdMXZW<^gy=O4@@e)xCtVX_C|kcf8o&kc6v_{Qx?8rm@l#@H zEi9UPd2B2fEWG5BiKBFx#;J|r6K2#IA{)KEBP=Z$+Rjw8Xs8#wTu$4iJ}d2Dg%H}6 zf~}H-h%zH1JLU$)3F#{jQMMY8hG_(U>n+l@81OuGT>Hs<-FI#K2N49{jK)VrH-m6j zz;tSiHX+Fkjg>kq1@FO5KosW=Xo;sg*m5>x@%o<`&_1XAPIad$?XBc6my|4xA8jpS z0D}c>#~Gpp-N~bfqIQ`Cwq)9=wGZ~}c?0|F=g7w4 zo;`2mU}GIwsrT)9^|0Mu^is?O?S&Dwp!wq>UbH3NFBVHU1{PeKb;Z|H54is2qxz}{ zZs9T&P(q^_W@iLHaZAG0q4DtwS2O2xd_=}!%pM3oIXc=)NA%dC9729g$PB47i@T#U Ld11nLya2ufH8S<< literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/notification_update_all_dto.dart b/mobile/openapi/lib/model/notification_update_all_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..a6393b275a576908312262f1044d1d0805a1941c GIT binary patch literal 3393 zcmbVPU2ob*6n*Dc+*DODTGyoeu!_h+8f}tQx=oPUCI?XEm62&IGr5$x4gGJmEDLQ9dWdW#)+R11 z8*BNsQX1WJvBZB%q4Cp7Z7{ethuxDRH&)~}Rw*V_GbJ^D+*TT&`f!W+$e3M$^z|iZVC^BkU3tMOy4ukQ-7n$H7>_X}u3@H>1 zHW67s1%>{}_wVi-sGp4I%^NN_PNgg_zg8ksb{!$gA~OMJp#DOtwgCUyzmFnY z+}kAR;pep}PX0K4=i7JTHy|@3>w*y$UrZw$rK^p(V9i^^hIG6kTL02^fyh&EqGLc= z1K&heNdq%oEbEvPKEj2Zp+T26cY>3jhCBmD?UgdTrwvY(TFy{9cpr6@;Lezswkbvt z!X=4OuNFm09CHV)JATHsFsL=iu0}uos+?<)O4OKu3k~5_8=+p|{*zu1o@`4iiM{wx z0DE*{lPD2ewb;=ta7seKYAD)r2Aq+zre@%wbv(oD*Qna!AtQm={W*hx7`x@8*xB`( z^^BNPBxk`~7b}W0fEh}L?Cr%H{2+^z$&qUSOHq31T#QIKf3%QkW>FXM5*=$aj0c8o71i%M@R`Pdmuh{{ic0Mh5@@ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/notification_update_dto.dart b/mobile/openapi/lib/model/notification_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..e76496eb9757f706021bd1477be3b3c70659a5d9 GIT binary patch literal 3031 zcmbVOZExE)5dQ98aVdsa0aQ8dhaqrhr@`zD>6#d5oeza!2#iEo>|{|RsTxM=|K2-N zv}HtTvlU1!QTM`op68An40?kBT;0!Sum76fO+Q`DrxUoo`8W+>GK0I>9ByZmo9izp zXhxRrQ>M)5ZS>}%N3UX4O2y+$syGt`A3|N$nx`=@_?nj{^zUL_No6`cSg~PS9apu9 z75}#u3f)V##{Vm(@YisqF}Tv*?ujh5VWo*hiU~y~xU%lLGg%=@uH!O4XE8~mv zILVpPy&k4BATy8^FIgob{Cn5yB?Z$O?j(gym=UtPuTp0C#7KDT0Spj@Pu$e1)WD$R z8(4H2FG1LiCOkr~PLqJ*0hnCqNO6{4nGhFtXSfT13Fe%OHNOP+$u7Q^-?&m(IT}1j zSYMNy!tgHB(z+8Hvle7TTHxu0Bdo)O^#D=^YM1h(XUC(sL*V)UE)UG^=Qx2*cZ`G$~gQGy~FW4I4mmKe-&lZlcQ@D-O} z1W&9VckgToA_EJt$LhqD;)8$)k?9HIybPp4B=I$JYJ!=s1b=qYe=1eRrojnM>4P`QNpx&B8b4rl!7%3bS4aJ zI%h>~KZG@_e8UH%wh;Dep~wCiO$PecY;|W}n;f2e%W?zQM}gbxOg@GJzg;7RN65LX zk9Ml8l-#g~0y`NphSCdn9tsD9;<_#jbwqcD#qt0s#U(sxLvVU(B^e2gj?;BzwmvXo zj8iOV;>!(APiuPk?pbgQjk+Zfku+^xSVDsp!FHhbNpI3;S5U;#)Cr*F>5!%tBEuux z8!scNw!nin1pj2Cv`J}CoNnYu2%Xe;92`Js)t*P)vD(wgvw%DJDGAnY;W$W| zd%zI(M|O;-CSDHc65z)A5uv!Mww}_6-UQdaB*N8|^+IZGw8$E_i24P^2-(6p{=>I4 zfvvyO>490#@Db1G+8Uxn5zRRZm2_wBMwlFzsIllq(aFbaXN#`Q9L2K+edvBgAyHmU z>)y7`B~sQiO*SSk`rfIR5MI{G7^AJEpXK{MdU~N$ULw0@0Jg@&FI9!b9dq7EOhmNp zL2QXD+;{S!r^CJCItBvfYYAll^PKNB;qSVD^3!JJ7VKF{vx d#@L9#9+5bo+(r3s^4(uV#*b)+R}dUAKLGcu*Ma~5 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 1244a434b6ee7d4b76badb676a3737b0adab3920..1735bc2eb5ca4b1cca1ea7a085130ae1ece90f71 100644 GIT binary patch delta 402 zcmcaxdcJx?J17 zmqJyMz>cP(kxzQE03XlfK54eeYx#sXujReMn*z5J=&FF!qRjjh=c3fal2jco1t7qb z4N6T+!6g@3kb+g`2Fpzo8s5SJ6mea)JT-W(9-2TmVV@l4t+` delta 26 icmX?KeYbQ&JMUy~e%{R&_}=nvHkO?(vYE^HC>H>u?+ViZ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1471020cd..f4ec92937 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -206,6 +206,141 @@ ] } }, + "/admin/notifications": { + "post": { + "operationId": "createNotification", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/templates/{name}": { + "post": { + "operationId": "getNotificationTemplateAdmin", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/test-email": { + "post": { + "operationId": "sendTestEmailAdmin", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemConfigSmtpDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, "/admin/users": { "get": { "operationId": "searchUsersAdmin", @@ -3485,15 +3620,224 @@ ] } }, - "/notifications/admin/templates/{name}": { - "post": { - "operationId": "getNotificationTemplateAdmin", + "/notifications": { + "delete": { + "operationId": "deleteNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDeleteAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotifications", "parameters": [ { - "name": "name", + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "level", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationLevel" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationType" + } + }, + { + "name": "unread", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NotificationDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationUpdateAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + } + }, + "/notifications/{id}": { + "delete": { + "operationId": "deleteNotification", + "parameters": [ + { + "name": "id", "required": true, "in": "path", "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", "type": "string" } } @@ -3502,7 +3846,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateDto" + "$ref": "#/components/schemas/NotificationUpdateDto" } } }, @@ -3513,7 +3857,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateResponseDto" + "$ref": "#/components/schemas/NotificationDto" } } }, @@ -3532,49 +3876,7 @@ } ], "tags": [ - "Notifications (Admin)" - ] - } - }, - "/notifications/admin/test-email": { - "post": { - "operationId": "sendTestEmailAdmin", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestEmailResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Notifications (Admin)" + "Notifications" ] } }, @@ -10326,6 +10628,157 @@ }, "type": "object" }, + "NotificationCreateDto": { + "properties": { + "data": { + "type": "object" + }, + "description": { + "nullable": true, + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "userId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "title", + "userId" + ], + "type": "object" + }, + "NotificationDeleteAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationDto": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "data": { + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "level", + "title", + "type" + ], + "type": "object" + }, + "NotificationLevel": { + "enum": [ + "success", + "error", + "warning", + "info" + ], + "type": "string" + }, + "NotificationType": { + "enum": [ + "JobFailed", + "BackupFailed", + "SystemMessage", + "Custom" + ], + "type": "string" + }, + "NotificationUpdateAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationUpdateDto": { + "properties": { + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, "OAuthAuthorizeResponseDto": { "properties": { "url": { @@ -10600,6 +11053,10 @@ "memory.read", "memory.update", "memory.delete", + "notification.create", + "notification.read", + "notification.update", + "notification.delete", "partner.create", "partner.read", "partner.update", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1ba4d3e23..647c5c4ad 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -39,6 +39,48 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type NotificationCreateDto = { + data?: object; + description?: string | null; + level?: NotificationLevel; + readAt?: string | null; + title: string; + "type"?: NotificationType; + userId: string; +}; +export type NotificationDto = { + createdAt: string; + data?: object; + description?: string; + id: string; + level: NotificationLevel; + readAt?: string; + title: string; + "type": NotificationType; +}; +export type TemplateDto = { + template: string; +}; +export type TemplateResponseDto = { + html: string; + name: string; +}; +export type SystemConfigSmtpTransportDto = { + host: string; + ignoreCert: boolean; + password: string; + port: number; + username: string; +}; +export type SystemConfigSmtpDto = { + enabled: boolean; + "from": string; + replyTo: string; + transport: SystemConfigSmtpTransportDto; +}; +export type TestEmailResponseDto = { + messageId: string; +}; export type UserLicense = { activatedAt: string; activationKey: string; @@ -661,28 +703,15 @@ export type MemoryUpdateDto = { memoryAt?: string; seenAt?: string; }; -export type TemplateDto = { - template: string; +export type NotificationDeleteAllDto = { + ids: string[]; }; -export type TemplateResponseDto = { - html: string; - name: string; +export type NotificationUpdateAllDto = { + ids: string[]; + readAt?: string | null; }; -export type SystemConfigSmtpTransportDto = { - host: string; - ignoreCert: boolean; - password: string; - port: number; - username: string; -}; -export type SystemConfigSmtpDto = { - enabled: boolean; - "from": string; - replyTo: string; - transport: SystemConfigSmtpTransportDto; -}; -export type TestEmailResponseDto = { - messageId: string; +export type NotificationUpdateDto = { + readAt?: string | null; }; export type OAuthConfigDto = { codeChallenge?: string; @@ -1453,6 +1482,43 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +export function createNotification({ notificationCreateDto }: { + notificationCreateDto: NotificationCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: NotificationDto; + }>("/admin/notifications", oazapfts.json({ + ...opts, + method: "POST", + body: notificationCreateDto + }))); +} +export function getNotificationTemplateAdmin({ name, templateDto }: { + name: string; + templateDto: TemplateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TemplateResponseDto; + }>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "POST", + body: templateDto + }))); +} +export function sendTestEmailAdmin({ systemConfigSmtpDto }: { + systemConfigSmtpDto: SystemConfigSmtpDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/admin/notifications/test-email", oazapfts.json({ + ...opts, + method: "POST", + body: systemConfigSmtpDto + }))); +} export function searchUsersAdmin({ withDeleted }: { withDeleted?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -2321,29 +2387,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getNotificationTemplateAdmin({ name, templateDto }: { - name: string; - templateDto: TemplateDto; +export function deleteNotifications({ notificationDeleteAllDto }: { + notificationDeleteAllDto: NotificationDeleteAllDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TemplateResponseDto; - }>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: templateDto + method: "DELETE", + body: notificationDeleteAllDto }))); } -export function sendTestEmailAdmin({ systemConfigSmtpDto }: { - systemConfigSmtpDto: SystemConfigSmtpDto; +export function getNotifications({ id, level, $type, unread }: { + id?: string; + level?: NotificationLevel; + $type?: NotificationType; + unread?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TestEmailResponseDto; - }>("/notifications/admin/test-email", oazapfts.json({ + data: NotificationDto[]; + }>(`/notifications${QS.query(QS.explode({ + id, + level, + "type": $type, + unread + }))}`, { + ...opts + })); +} +export function updateNotifications({ notificationUpdateAllDto }: { + notificationUpdateAllDto: NotificationUpdateAllDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: systemConfigSmtpDto + method: "PUT", + body: notificationUpdateAllDto + }))); +} +export function deleteNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateNotification({ id, notificationUpdateDto }: { + id: string; + notificationUpdateDto: NotificationUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: notificationUpdateDto }))); } export function startOAuth({ oAuthConfigDto }: { @@ -3452,6 +3560,18 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum NotificationLevel { + Success = "success", + Error = "error", + Warning = "warning", + Info = "info" +} +export enum NotificationType { + JobFailed = "JobFailed", + BackupFailed = "BackupFailed", + SystemMessage = "SystemMessage", + Custom = "Custom" +} export enum UserStatus { Active = "active", Removing = "removing", @@ -3526,6 +3646,10 @@ export enum Permission { MemoryRead = "memory.read", MemoryUpdate = "memory.update", MemoryDelete = "memory.delete", + NotificationCreate = "notification.create", + NotificationRead = "notification.read", + NotificationUpdate = "notification.update", + NotificationDelete = "notification.delete", PartnerCreate = "partner.create", PartnerRead = "partner.read", PartnerUpdate = "partner.update", diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 0da0aac8b..e36793b3d 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller'; import { MapController } from 'src/controllers/map.controller'; import { MemoryController } from 'src/controllers/memory.controller'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller'; +import { NotificationController } from 'src/controllers/notification.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; @@ -47,6 +48,7 @@ export const controllers = [ LibraryController, MapController, MemoryController, + NotificationController, NotificationAdminController, OAuthController, PartnerController, diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index 937244fc5..9bac865bd 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -1,16 +1,28 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; +import { + NotificationCreateDto, + NotificationDto, + TemplateDto, + TemplateResponseDto, + TestEmailResponseDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; -import { NotificationService } from 'src/services/notification.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; @ApiTags('Notifications (Admin)') -@Controller('notifications/admin') +@Controller('admin/notifications') export class NotificationAdminController { - constructor(private service: NotificationService) {} + constructor(private service: NotificationAdminService) {} + + @Post() + @Authenticated({ admin: true }) + createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise { + return this.service.create(auth, dto); + } @Post('test-email') @HttpCode(HttpStatus.OK) diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts new file mode 100644 index 000000000..c64f78685 --- /dev/null +++ b/server/src/controllers/notification.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { NotificationService } from 'src/services/notification.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Notifications') +@Controller('notifications') +export class NotificationController { + constructor(private service: NotificationService) {} + + @Get() + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Put() + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise { + return this.service.updateAll(auth, dto); + } + + @Delete() + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotification( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: NotificationUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/database.ts b/server/src/database.ts index 0dab61cbe..a93873ef4 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -333,6 +333,7 @@ export const columns = { ], tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], + notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], syncAsset: [ 'id', 'ownerId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4e9738ece..85be9d520 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -11,6 +11,8 @@ import { AssetStatus, AssetType, MemoryType, + NotificationLevel, + NotificationType, Permission, SharedLinkType, SourceType, @@ -263,6 +265,21 @@ export interface Memories { updateId: Generated; } +export interface Notifications { + id: Generated; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; + updateId: Generated; + userId: string; + level: Generated; + type: NotificationType; + title: string; + description: string | null; + data: any | null; + readAt: Timestamp | null; +} + export interface MemoriesAssetsAssets { assetsId: string; memoriesId: string; @@ -463,6 +480,7 @@ export interface DB { memories: Memories; memories_assets_assets: MemoriesAssetsAssets; migrations: Migrations; + notifications: Notifications; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; partners_audit: PartnersAudit; diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index c1a09c801..d9847cda1 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,4 +1,7 @@ -import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class TestEmailResponseDto { messageId!: string; @@ -11,3 +14,106 @@ export class TemplateDto { @IsString() template!: string; } + +export class NotificationDto { + id!: string; + @ValidateDate() + createdAt!: Date; + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level!: NotificationLevel; + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type!: NotificationType; + title!: string; + description?: string; + data?: any; + readAt?: Date; +} + +export class NotificationSearchDto { + @Optional() + @ValidateUUID({ optional: true }) + id?: string; + + @IsEnum(NotificationLevel) + @Optional() + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @ValidateBoolean({ optional: true }) + unread?: boolean; +} + +export class NotificationCreateDto { + @Optional() + @IsEnum(NotificationLevel) + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @IsString() + title!: string; + + @IsString() + @Optional({ nullable: true }) + description?: string | null; + + @Optional({ nullable: true }) + data?: any; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; + + @ValidateUUID() + userId!: string; +} + +export class NotificationUpdateDto { + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationUpdateAllDto { + @ValidateUUID({ each: true, optional: true }) + ids!: string[]; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationDeleteAllDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + +export type MapNotification = { + id: string; + createdAt: Date; + updateId?: string; + level: NotificationLevel; + type: NotificationType; + data: any | null; + title: string; + description: string | null; + readAt: Date | null; +}; +export const mapNotification = (notification: MapNotification): NotificationDto => { + return { + id: notification.id, + createdAt: notification.createdAt, + level: notification.level, + type: notification.type, + title: notification.title, + description: notification.description ?? undefined, + data: notification.data ?? undefined, + readAt: notification.readAt ?? undefined, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index b9a914671..9fb6168b1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -126,6 +126,11 @@ export enum Permission { MEMORY_UPDATE = 'memory.update', MEMORY_DELETE = 'memory.delete', + NOTIFICATION_CREATE = 'notification.create', + NOTIFICATION_READ = 'notification.read', + NOTIFICATION_UPDATE = 'notification.update', + NOTIFICATION_DELETE = 'notification.delete', + PARTNER_CREATE = 'partner.create', PARTNER_READ = 'partner.read', PARTNER_UPDATE = 'partner.update', @@ -515,6 +520,7 @@ export enum JobName { NOTIFY_SIGNUP = 'notify-signup', NOTIFY_ALBUM_INVITE = 'notify-album-invite', NOTIFY_ALBUM_UPDATE = 'notify-album-update', + NOTIFICATIONS_CLEANUP = 'notifications-cleanup', SEND_EMAIL = 'notification-send-email', // Version check @@ -580,3 +586,17 @@ export enum SyncEntityType { PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', } + +export enum NotificationLevel { + Success = 'success', + Error = 'error', + Warning = 'warning', + Info = 'info', +} + +export enum NotificationType { + JobFailed = 'JobFailed', + BackupFailed = 'BackupFailed', + SystemMessage = 'SystemMessage', + Custom = 'Custom', +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index dd58aebcb..03f1af3b2 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -157,6 +157,15 @@ where and "memories"."ownerId" = $2 and "memories"."deletedAt" is null +-- AccessRepository.notification.checkOwnerAccess +select + "notifications"."id" +from + "notifications" +where + "notifications"."id" in ($1) + and "notifications"."userId" = $2 + -- AccessRepository.person.checkOwnerAccess select "person"."id" diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql new file mode 100644 index 000000000..c55e00d22 --- /dev/null +++ b/server/src/queries/notification.repository.sql @@ -0,0 +1,58 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- NotificationRepository.cleanup +delete from "notifications" +where + ( + ( + "deletedAt" is not null + and "deletedAt" < $1 + ) + or ( + "readAt" > $2 + and "createdAt" < $3 + ) + or ( + "readAt" = $4 + and "createdAt" < $5 + ) + ) + +-- NotificationRepository.search +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + "userId" = $1 + and "deletedAt" is null +order by + "createdAt" desc + +-- NotificationRepository.search (unread) +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + ( + "userId" = $1 + and "readAt" is null + ) + and "deletedAt" is null +order by + "createdAt" desc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 961cccbf3..c24209e48 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -279,6 +279,26 @@ class AuthDeviceAccess { } } +class NotificationAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, notificationIds: Set) { + if (notificationIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('notifications') + .select('notifications.id') + .where('notifications.id', 'in', [...notificationIds]) + .where('notifications.userId', '=', userId) + .execute() + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class StackAccess { constructor(private db: Kysely) {} @@ -426,6 +446,7 @@ export class AccessRepository { asset: AssetAccess; authDevice: AuthDeviceAccess; memory: MemoryAccess; + notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; stack: StackAccess; @@ -438,6 +459,7 @@ export class AccessRepository { this.asset = new AssetAccess(db); this.authDevice = new AuthDeviceAccess(db); this.memory = new MemoryAccess(db); + this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); this.stack = new StackAccess(db); diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 3156804d0..b41c007ef 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config'; import { EventConfig } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -64,6 +65,7 @@ type EventMap = { 'assets.restore': [{ assetIds: string[]; userId: string }]; 'job.start': [QueueName, JobItem]; + 'job.failed': [{ job: JobItem; error: Error | any }]; // session events 'session.delete': [{ sessionId: string }]; @@ -104,6 +106,7 @@ export interface ClientEventMap { on_server_version: [ServerVersionResponseDto]; on_config_update: []; on_new_release: [ReleaseNotification]; + on_notification: [NotificationDto]; on_session_delete: [string]; } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index bd2e5c677..453e515fe 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -55,6 +56,7 @@ export const repositories = [ CryptoRepository, DatabaseRepository, DownloadRepository, + EmailRepository, EventRepository, JobRepository, LibraryRepository, @@ -65,7 +67,7 @@ export const repositories = [ MemoryRepository, MetadataRepository, MoveRepository, - EmailRepository, + NotificationRepository, OAuthRepository, PartnerRepository, PersonRepository, diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts new file mode 100644 index 000000000..112bb97e6 --- /dev/null +++ b/server/src/repositories/notification.repository.ts @@ -0,0 +1,103 @@ +import { Insertable, Kysely, Updateable } from 'kysely'; +import { DateTime } from 'luxon'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, Notifications } from 'src/db'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { NotificationSearchDto } from 'src/dtos/notification.dto'; + +export class NotificationRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + cleanup() { + return this.db + .deleteFrom('notifications') + .where((eb) => + eb.or([ + // remove soft-deleted notifications + eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]), + + // remove old, read notifications + eb.and([ + // keep recently read messages around for a few days + eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()), + eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()), + ]), + + eb.and([ + // remove super old, unread notifications + eb('readAt', '=', null), + eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()), + ]), + ]), + ) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] }) + search(userId: string, dto: NotificationSearchDto) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where((qb) => + qb.and({ + userId, + id: dto.id, + level: dto.level, + type: dto.type, + readAt: dto.unread ? null : undefined, + }), + ) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .execute(); + } + + create(notification: Insertable) { + return this.db + .insertInto('notifications') + .values(notification) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + get(id: string) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where('id', '=', id) + .where('deletedAt', 'is not', null) + .executeTakeFirst(); + } + + update(id: string, notification: Updateable) { + return this.db + .updateTable('notifications') + .set(notification) + .where('deletedAt', 'is', null) + .where('id', '=', id) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + async updateAll(ids: string[], notification: Updateable) { + await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute(); + } + + async delete(id: string) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', '=', id) + .execute(); + } + + async deleteAll(ids: string[]) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', 'in', ids) + .execute(); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index fe4b86d65..d297b2217 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table'; import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import { MoveTable } from 'src/schema/tables/move.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; +import { NotificationTable } from 'src/schema/tables/notification.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; @@ -76,6 +77,7 @@ export class ImmichDatabase { MemoryTable, MoveTable, NaturalEarthCountriesTable, + NotificationTable, PartnerAuditTable, PartnerTable, PersonTable, diff --git a/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts new file mode 100644 index 000000000..28dca6658 --- /dev/null +++ b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at" + BEFORE UPDATE ON "notifications" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db); + await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db); + await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db); + await sql`DROP TABLE "notifications";`.execute(db); +} diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts new file mode 100644 index 000000000..bf9b8bdf3 --- /dev/null +++ b/server/src/schema/tables/notification.table.ts @@ -0,0 +1,52 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, +} from 'src/sql-tools'; + +@Table('notifications') +@UpdatedAtTrigger('notifications_updated_at') +export class NotificationTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @DeleteDateColumn() + deletedAt?: Date; + + @UpdateIdColumn({ indexName: 'IDX_notifications_update_id' }) + updateId?: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + userId!: string; + + @Column({ default: NotificationLevel.Info }) + level!: NotificationLevel; + + @Column({ default: NotificationLevel.Info }) + type!: NotificationType; + + @Column({ type: 'jsonb', nullable: true }) + data!: any | null; + + @Column() + title!: string; + + @Column({ type: 'text', nullable: true }) + description!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + readAt?: Date | null; +} diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 704087ab0..aa72fd588 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -142,52 +142,55 @@ describe(BackupService.name, () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); + it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); + it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.rename).toHaveBeenCalled(); }); + it('should fail if pg_dumpall fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); }); + it('should not rename file if pgdump fails and gzip succeeds', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.rename).not.toHaveBeenCalled(); }); + it('should fail if gzip fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1'); }); + it('should fail if write stream fails', async () => { mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should fail if rename fails', async () => { mocks.storage.rename.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should ignore unlink failing and still return failed job status', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.storage.unlink.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.unlink).toHaveBeenCalled(); - expect(result).toBe(JobStatus.FAILED); }); + it.each` postgresVersion | expectedVersion ${'14.10'} | ${14} diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 409d34ab7..10f7becc7 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -174,7 +174,7 @@ export class BackupService extends BaseService { await this.storageRepository .unlink(backupFilePath) .catch((error) => this.logger.error('Failed to delete failed backup file', error)); - return JobStatus.FAILED; + throw error; } this.logger.log(`Database Backup Success`); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 23ddb1b63..3381ad722 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -80,6 +81,7 @@ export class BaseService { protected memoryRepository: MemoryRepository, protected metadataRepository: MetadataRepository, protected moveRepository: MoveRepository, + protected notificationRepository: NotificationRepository, protected oauthRepository: OAuthRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b214dd14f..88b68d2c1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service'; import { MediaService } from 'src/services/media.service'; import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; import { NotificationService } from 'src/services/notification.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; @@ -60,6 +61,7 @@ export const services = [ MemoryService, MetadataService, NotificationService, + NotificationAdminService, PartnerService, PersonService, SearchService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b81256de8..a387e6e09 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -215,11 +215,7 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error( - `Unable to run job handler (${queueName}/${job.name}): ${error}`, - error?.stack, - JSON.stringify(job.data), - ); + await this.eventRepository.emit('job.failed', { job, error }); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts new file mode 100644 index 000000000..4a747d41a --- /dev/null +++ b/server/src/services/notification-admin.service.spec.ts @@ -0,0 +1,111 @@ +import { defaults, SystemConfig } from 'src/config'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { NotificationService } from 'src/services/notification.service'; +import { userStub } from 'test/fixtures/user.stub'; +import { newTestService, ServiceMocks } from 'test/utils'; + +const smtpTransport = Object.freeze({ + ...defaults, + notifications: { + smtp: { + ...defaults.notifications.smtp, + enabled: true, + transport: { + ignoreCert: false, + host: 'localhost', + port: 587, + username: 'test', + password: 'test', + }, + }, + }, +}); + +describe(NotificationService.name, () => { + let sut: NotificationService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(NotificationService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('sendTestEmail', () => { + it('should throw error if user could not be found', async () => { + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); + }); + + it('should throw error if smtp validation fails', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockRejectedValue(''); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow( + 'Failed to verify SMTP configuration', + ); + }); + + it('should send email to default domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email to external domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email with replyTo', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect( + sut.sendTestEmail('', { ...smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), + ).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + replyTo: 'demo@immich.app', + }), + ); + }); + }); +}); diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts new file mode 100644 index 000000000..bf0d2bba4 --- /dev/null +++ b/server/src/services/notification-admin.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { BaseService } from 'src/services/base.service'; +import { getExternalDomain } from 'src/utils/misc'; + +@Injectable() +export class NotificationAdminService extends BaseService { + async create(auth: AuthDto, dto: NotificationCreateDto) { + const item = await this.notificationRepository.create({ + userId: dto.userId, + type: dto.type ?? NotificationType.Custom, + level: dto.level ?? NotificationLevel.Info, + title: dto.title, + description: dto.description, + data: dto.data, + }); + + return mapNotification(item); + } + + async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) { + const user = await this.userRepository.get(id, { withDeleted: false }); + if (!user) { + throw new Error('User not found'); + } + + try { + await this.emailRepository.verifySmtp(dto.transport); + } catch (error) { + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); + } + + const { server } = await this.getConfig({ withCache: false }); + const { html, text } = await this.emailRepository.renderEmail({ + template: EmailTemplate.TEST_EMAIL, + data: { + baseUrl: getExternalDomain(server), + displayName: user.name, + }, + customTemplate: tempTemplate!, + }); + const { messageId } = await this.emailRepository.sendEmail({ + to: user.email, + subject: 'Test email from Immich', + html, + text, + from: dto.from, + replyTo: dto.replyTo || dto.from, + smtp: dto.transport, + }); + + return { messageId }; + } + + async getTemplate(name: EmailTemplate, customTemplate: string) { + const { server, templates } = await this.getConfig({ withCache: false }); + + let templateResponse = ''; + + switch (name) { + case EmailTemplate.WELCOME: { + const { html: _welcomeHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.WELCOME, + data: { + baseUrl: getExternalDomain(server), + displayName: 'John Doe', + username: 'john@doe.com', + password: 'thisIsAPassword123', + }, + customTemplate: customTemplate || templates.email.welcomeTemplate, + }); + + templateResponse = _welcomeHtml; + break; + } + case EmailTemplate.ALBUM_UPDATE: { + const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: 'Favorite Photos', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = _updateAlbumHtml; + break; + } + + case EmailTemplate.ALBUM_INVITE: { + const { html } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: "John Doe's Favorites", + senderName: 'John Doe', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = html; + break; + } + default: { + templateResponse = ''; + break; + } + } + + return { name, html: templateResponse }; + } +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 583026075..133eb9e7f 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -3,7 +3,6 @@ import { defaults, SystemConfig } from 'src/config'; import { AlbumUser } from 'src/database'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; -import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; @@ -241,82 +240,6 @@ describe(NotificationService.name, () => { }); }); - describe('sendTestEmail', () => { - it('should throw error if user could not be found', async () => { - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); - }); - - it('should throw error if smtp validation fails', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockRejectedValue(''); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( - 'Failed to verify SMTP configuration', - ); - }); - - it('should send email to default domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email to external domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email with replyTo', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect( - sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), - ).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - replyTo: 'demo@immich.app', - }), - ); - }); - }); - describe('handleUserSignup', () => { it('should skip if user could not be found', async () => { await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 573be90f9..be475d1dc 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,24 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapNotification, + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; -import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; +import { + AssetFileType, + JobName, + JobStatus, + NotificationLevel, + NotificationType, + Permission, + QueueName, +} from 'src/enum'; import { EmailTemplate } from 'src/repositories/email.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -15,6 +32,80 @@ import { getPreferences } from 'src/utils/preferences'; export class NotificationService extends BaseService { private static albumUpdateEmailDelayMs = 300_000; + async search(auth: AuthDto, dto: NotificationSearchDto): Promise { + const items = await this.notificationRepository.search(auth.user.id, dto); + return items.map((item) => mapNotification(item)); + } + + async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE }); + await this.notificationRepository.updateAll(dto.ids, { + readAt: dto.readAt, + }); + } + + async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.deleteAll(dto.ids); + } + + async get(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ }); + const item = await this.notificationRepository.get(id); + if (!item) { + throw new BadRequestException('Notification not found'); + } + return mapNotification(item); + } + + async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE }); + const item = await this.notificationRepository.update(id, { + readAt: dto.readAt, + }); + return mapNotification(item); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.delete(id); + } + + @OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + async onNotificationsCleanup() { + await this.notificationRepository.cleanup(); + } + + @OnEvent({ name: 'job.failed' }) + async onJobFailed({ job, error }: ArgOf<'job.failed'>) { + const admin = await this.userRepository.getAdmin(); + if (!admin) { + return; + } + + this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); + + switch (job.name) { + case JobName.BACKUP_DATABASE: { + const errorMessage = error instanceof Error ? error.message : error; + const item = await this.notificationRepository.create({ + userId: admin.id, + type: NotificationType.JobFailed, + level: NotificationLevel.Error, + title: 'Job Failed', + description: `Job ${[job.name]} failed with error: ${errorMessage}`, + }); + + this.eventRepository.clientSend('on_notification', admin.id, mapNotification(item)); + break; + } + + default: { + return; + } + } + } + @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { this.eventRepository.clientBroadcast('on_config_update'); diff --git a/server/src/types.ts b/server/src/types.ts index c5375ae72..ba33e97aa 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -297,6 +297,10 @@ export type JobItem = // Metadata Extraction | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + + // Notifications + | { name: JobName.NOTIFICATIONS_CLEANUP; data?: IBaseJob } + // Sidecar Scanning | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 4e21a9226..b04d23f11 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -221,6 +221,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } + case Permission.NOTIFICATION_READ: + case Permission.NOTIFICATION_UPDATE: + case Permission.NOTIFICATION_DELETE: { + return access.notification.checkOwnerAccess(auth.user.id, ids); + } + case Permission.TAG_ASSET: case Permission.TAG_READ: case Permission.TAG_UPDATE: diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 671a8a50c..3684837ba 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -13,9 +13,11 @@ import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EmailRepository } from 'src/repositories/email.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; @@ -42,10 +44,12 @@ type RepositoriesTypes = { config: ConfigRepository; crypto: CryptoRepository; database: DatabaseRepository; + email: EmailRepository; job: JobRepository; user: UserRepository; logger: LoggingRepository; memory: MemoryRepository; + notification: NotificationRepository; partner: PartnerRepository; person: PersonRepository; search: SearchRepository; @@ -142,6 +146,11 @@ export const getRepository = (key: K, db: Kys return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo); } + case 'email': { + const logger = new LoggingRepository(undefined, new ConfigRepository()); + return new EmailRepository(logger); + } + case 'logger': { const configMock = { getEnv: () => ({ noColor: false }) }; return new LoggingRepository(undefined, configMock as ConfigRepository); @@ -151,6 +160,10 @@ export const getRepository = (key: K, db: Kys return new MemoryRepository(db); } + case 'notification': { + return new NotificationRepository(db); + } + case 'partner': { return new PartnerRepository(db); } @@ -221,6 +234,10 @@ const getRepositoryMock = (key: K) => { }); } + case 'email': { + return automock(EmailRepository, { args: [{ setContext: () => {} }] }); + } + case 'job': { return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] }); } @@ -234,6 +251,10 @@ const getRepositoryMock = (key: K) => { return automock(MemoryRepository); } + case 'notification': { + return automock(NotificationRepository); + } + case 'partner': { return automock(PartnerRepository); } @@ -284,7 +305,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.crypto || getRepositoryMock('crypto'), repositories.database || getRepositoryMock('database'), repositories.downloadRepository, - repositories.email, + repositories.email || getRepositoryMock('email'), repositories.event, repositories.job || getRepositoryMock('job'), repositories.library, @@ -294,6 +315,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.memory || getRepositoryMock('memory'), repositories.metadata, repositories.move, + repositories.notification || getRepositoryMock('notification'), repositories.oauth, repositories.partner || getRepositoryMock('partner'), repositories.person || getRepositoryMock('person'), diff --git a/server/test/medium/specs/controllers/notification.controller.spec.ts b/server/test/medium/specs/controllers/notification.controller.spec.ts new file mode 100644 index 000000000..f4a0ec82d --- /dev/null +++ b/server/test/medium/specs/controllers/notification.controller.spec.ts @@ -0,0 +1,86 @@ +import { NotificationController } from 'src/controllers/notification.controller'; +import { AuthService } from 'src/services/auth.service'; +import { NotificationService } from 'src/services/notification.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { createControllerTestApp, TestControllerApp } from 'test/medium/utils'; +import { factory } from 'test/small.factory'; + +describe(NotificationController.name, () => { + let realApp: TestControllerApp; + let mockApp: TestControllerApp; + + beforeEach(async () => { + realApp = await createControllerTestApp({ authType: 'real' }); + mockApp = await createControllerTestApp({ authType: 'mock' }); + }); + + describe('GET /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get('/notifications'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should call the service with an auth dto', async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status } = await request(mockApp.getHttpServer()) + .get('/notifications') + .set('Authorization', `Bearer token`); + + expect(status).toBe(200); + expect(service.search).toHaveBeenCalledWith(auth, {}); + }); + + it(`should reject an invalid notification level`, async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status, body } = await request(mockApp.getHttpServer()) + .get(`/notifications`) + .query({ level: 'invalid' }) + .set('Authorization', `Bearer token`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')])); + expect(service.search).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications`) + .send({ ids: [], readAt: new Date().toISOString() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('GET /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('PUT /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications/${factory.uuid()}`) + .send({ readAt: factory.date() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + afterAll(async () => { + await realApp.close(); + await mockApp.close(); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index ec5115b83..5b98b95e2 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -37,6 +37,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + notification: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + person: { checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 919cdd4b1..d2742f7f8 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -314,4 +314,5 @@ export const factory = { sidecarWrite: assetSidecarWriteFactory, }, uuid: newUuid, + date: newDate, }; diff --git a/server/test/utils.ts b/server/test/utils.ts index c7c29d310..2c444f491 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -135,6 +136,7 @@ export type ServiceOverrides = { memory: MemoryRepository; metadata: MetadataRepository; move: MoveRepository; + notification: NotificationRepository; oauth: OAuthRepository; partner: PartnerRepository; person: PersonRepository; @@ -202,6 +204,7 @@ export const newTestService = ( memory: automock(MemoryRepository), metadata: newMetadataRepositoryMock(), move: automock(MoveRepository, { strict: false }), + notification: automock(NotificationRepository), oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: newPersonRepositoryMock(), @@ -250,6 +253,7 @@ export const newTestService = ( overrides.memory || (mocks.memory as As), overrides.metadata || (mocks.metadata as As), overrides.move || (mocks.move as As), + overrides.notification || (mocks.notification as As), overrides.oauth || (mocks.oauth as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index e91db5cc3..2ebe4feba 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -8,6 +8,7 @@ import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte'; import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; + import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/stores/auth-manager.svelte'; @@ -18,13 +19,14 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; import { Button, IconButton } from '@immich/ui'; - import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; + import { mdiBellBadge, mdiBellOutline, mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; + import { notificationManager } from '$lib/stores/notification-manager.svelte'; interface Props { showUploadButton?: boolean; @@ -36,7 +38,9 @@ let shouldShowAccountInfo = $state(false); let shouldShowAccountInfoPanel = $state(false); let shouldShowHelpPanel = $state(false); + let shouldShowNotificationPanel = $state(false); let innerWidth: number = $state(0); + const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0); let info: ServerAboutResponseDto | undefined = $state(); @@ -146,6 +150,27 @@ /> +

(shouldShowNotificationPanel = false), + onEscape: () => (shouldShowNotificationPanel = false), + }} + > + (shouldShowNotificationPanel = !shouldShowNotificationPanel)} + aria-label={$t('notifications')} + /> + + {#if shouldShowNotificationPanel} + + {/if} +
+
(shouldShowAccountInfoPanel = false), diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte new file mode 100644 index 000000000..0d05e2d6d --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte @@ -0,0 +1,114 @@ + + + diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte new file mode 100644 index 000000000..be9fcd2a4 --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte @@ -0,0 +1,82 @@ + + +
+ +
+ {$t('notifications')} +
+ +
+
+ +
+ + {#if noUnreadNotifications} + + + {$t('no_notifications')} + + {:else} + + + {#each notificationManager.notifications as notification (notification.id)} +
+ markAsRead(id)} /> +
+ {/each} +
+
+ {/if} +
+
diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts new file mode 100644 index 000000000..c06400fd1 --- /dev/null +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -0,0 +1,38 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; +import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk'; + +class NotificationStore { + notifications = $state([]); + + constructor() { + // TODO replace this with an `auth.login` event + this.refresh().catch(() => {}); + + eventManager.on('auth.logout', () => this.clear()); + } + + get hasUnread() { + return this.notifications.length > 0; + } + + refresh = async () => { + this.notifications = await getNotifications({ unread: true }); + }; + + markAsRead = async (id: string) => { + this.notifications = this.notifications.filter((notification) => notification.id !== id); + await updateNotification({ id, notificationUpdateDto: { readAt: new Date().toISOString() } }); + }; + + markAllAsRead = async () => { + const ids = this.notifications.map(({ id }) => id); + this.notifications = []; + await updateNotifications({ notificationUpdateAllDto: { ids, readAt: new Date().toISOString() } }); + }; + + clear = () => { + this.notifications = []; + }; +} + +export const notificationManager = new NotificationStore(); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 90228a5cb..ccfcfb780 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,6 +1,7 @@ import { authManager } from '$lib/stores/auth-manager.svelte'; +import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; -import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; +import { type AssetResponseDto, type NotificationDto, type ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; import { user } from './user.store'; @@ -26,6 +27,7 @@ export interface Events { on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; on_session_delete: (sessionId: string) => void; + on_notification: (notification: NotificationDto) => void; } const websocket: Socket = io({ @@ -50,6 +52,7 @@ websocket .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) .on('on_session_delete', () => authManager.logout()) + .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index aa756ac2e..1dcb91f99 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -10,6 +10,7 @@ import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { notificationManager } from '$lib/stores/notification-manager.svelte'; interface Props { data: PageData; @@ -24,7 +25,10 @@ let loading = $state(false); let oauthLoading = $state(true); - const onSuccess = async () => await goto(AppRoute.PHOTOS, { invalidateAll: true }); + const onSuccess = async () => { + await notificationManager.refresh(); + await goto(AppRoute.PHOTOS, { invalidateAll: true }); + }; const onFirstLogin = async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD); const onOnboarding = async () => await goto(AppRoute.AUTH_ONBOARDING);