From a2ba36c16d13a051f74219fe6a7e40c2529b5eb8 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 8 Jan 2026 14:52:16 -0500 Subject: [PATCH] feat: bulk asset metadata endpoints (#25133) --- mobile/openapi/README.md | Bin 49262 -> 49853 bytes mobile/openapi/lib/api.dart | Bin 15868 -> 16092 bytes mobile/openapi/lib/api/assets_api.dart | Bin 46239 -> 49724 bytes mobile/openapi/lib/api_client.dart | Bin 38898 -> 39339 bytes mobile/openapi/lib/api_helper.dart | Bin 7742 -> 7632 bytes .../model/asset_metadata_bulk_delete_dto.dart | Bin 0 -> 3082 bytes .../asset_metadata_bulk_delete_item_dto.dart | Bin 0 -> 3297 bytes .../asset_metadata_bulk_response_dto.dart | Bin 0 -> 3751 bytes .../model/asset_metadata_bulk_upsert_dto.dart | Bin 0 -> 3082 bytes .../asset_metadata_bulk_upsert_item_dto.dart | Bin 0 -> 3508 bytes .../openapi/lib/model/asset_metadata_key.dart | Bin 2591 -> 0 bytes .../model/asset_metadata_response_dto.dart | Bin 3453 -> 3440 bytes .../model/asset_metadata_upsert_item_dto.dart | Bin 3210 -> 3197 bytes .../model/sync_asset_metadata_delete_v1.dart | Bin 3215 -> 3202 bytes .../lib/model/sync_asset_metadata_v1.dart | Bin 3312 -> 3299 bytes open-api/immich-openapi-specs.json | 224 ++++++++++++++++-- open-api/typescript-sdk/src/fetch-client.ts | 59 ++++- .../src/controllers/asset.controller.spec.ts | 92 +++++-- server/src/controllers/asset.controller.ts | 29 +++ server/src/dtos/asset.dto.ts | 55 ++++- server/src/dtos/sync.dto.ts | 7 +- server/src/queries/asset.repository.sql | 8 + server/src/repositories/asset.repository.ts | 35 ++- .../tables/asset-metadata-audit.table.ts | 3 +- .../src/schema/tables/asset-metadata.table.ts | 2 +- server/src/services/asset.service.ts | 28 ++- server/test/medium.factory.ts | 7 + .../specs/services/asset.service.spec.ts | 175 +++++++++++++- .../repositories/asset.repository.mock.ts | 4 +- 29 files changed, 635 insertions(+), 93 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart delete mode 100644 mobile/openapi/lib/model/asset_metadata_key.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7277a99ac8bd240385ff599106ec06c6ba2a67f4..657e62bf6d9bc02451c9e9d5313f474a6ce7bcdf 100644 GIT binary patch delta 311 zcmaFYz`VDWdBZf($p)gVEKa34*^?E;mAO(hh5SQUGbFyQrJj8lk_+y7fVY{zQDybd5$dijL3G%$K_oXHl$KzdJXcC&@`5&AHkZ_#)RNT6f5apv@9$#c$Sut& z$t=i8ot#}&Fj=RYL&m8zCmW*1r6fOAA+fkPwZu2IBrzqiByn=StT78n!Q_w9QW}oP zQYhNcbe)xr*<-Dn3#`j*mTo9Fu*VRG}e_h=$4Xk`FigyQDC}6yI~TfhjsD ztu^_B8OP=rw-3H7#i>OllP9*xK^VAVMFHfi&;pQR%vb?Oh)=gXD1mU5762nE6)Xgd zBOR2GKrsY0j=&nucS}rm(01lPjw&HoR3S7^KF|Gq@`KJL*ipTneJZCCJ9j`aStf5czq1(4>8_FKn~Vlwbt_ Dukcws delta 214 zcmdnf!aV;a(}qLhlP}EWW%f?3oUHFHJ^2C`*W|b2+LHr%A!2e8%enPR@`Fo?GV{_k zG$%i>I6V16K;&d=`4yATiB(J%&~Zf3U#B2AxkOrIGVesN{>^>T5^S6E)Xfx7RDo=n zV8Ak2$AM?^eRo-Wwr#!;z{of`*J}@w0pT#?C-+HfO}-z*v3Z~O2j9&v7X~W;0Q;X& Aj{pDw diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 39aea82c894999af723eb00accbaee9cea39cd9b..6f68e44cefb77b32d2c8f1d92f124a8c989b22de 100644 GIT binary patch delta 165 zcmeygo@w=FrVW~!%ub~_lN(ayCOc^IPX41{B;=b~l9-ZMk_Z&fc1g`iElHi+*CP#* z=0ym30!2SIsZ8FW&dP(VVe&;~pr9rP3rOi?M;!xH*$a7uL)9{`*1?Y+Sm);)_C3BbrDMW?>94R zgSldoYa{};=k8Sh}w8w}v;@B8ViKPPvS4_Eh-FwjLL z8Ckwfg>l1QqhDV4=u#~UZDhRACSIsqUO-h;R;IDcXc3EYZwmqKIGr z94DDDw%5aS4)g+aDGO1m2tSv-UXlxI;mTU+Zlx2caN_qW|2)PHog6z2k3E0^Lh(Vm z$`lp^6mku-ZjU)AE;*4Ax^~+I6dAx}%0|k`m4zX2w=kQB_-K2My-vaQG8{&HIe!ttj7k_Yl$z z3Rfi~jQGy#0^@lK7pf3Be8O8u;dj=L+e?)8Fo7lp{R1y4T)r}EUUQ1NNMB(hl`bJ zssxo~W71rVW9n2A<3wjVC5HTs6BF?ZFe@=x=G z-FRyfFZR;}12~|G14fC!;rcJVfmad*s|o4O8gNd|OCx;I)$NU+)F{VkzD5eTX;REy z59c)W^j9~s<;%JPqGTtD8rV~tfw#gzvIb#F8p0#*hNaPK@sQ)-!2*X?hT8@$kClkJhnAQ z?e)>!!w6QQ+{vQNR8zEv3ayBYMzALWHcN+4Xh^|YPIAI_L(f^)VBk8c4+R&ndw<8m zT0AMxiNJ5>1Ag(Y*)$ACbfu(i`XYpVZ~upB4jHjWi?=5jNAPCe@w42}_GN#s-96C_ z;Rm#%>u&fE0KBAa$#`l1N?j|L;VqgtI%9M*^U6D*f4!3NWrB`$?4r0Rxg*LUwVfJ4 z)(;sS)zEXN9n-hf^<>f?(bzNP^BgJG+p@Juo>UrkcqoBxo(?y4OK{G`6EUD9EglC1il9OXJxIX!ti`l6I0D{%oZiuCvet$_CN|E0k?(FT Sj2;=7`jo*3$;%$Dq<;Z;VD;i=8rQJ zBgwZZQ)c{q^xc~QEyb#oipQB$aV83W0d-kxp2oc3YhIeLS&MZgl{u=xiVfT9xT;O8 z_@A{@0eBud~#^hX`!*~L&)CnsBiX&`JzJ7II zAWjzS)hipcHQD5Wu9^_x;2eXbvtzM}(!h!v$eGT6mMO>Rtl^fL#K@8-Tx+Jb#3&?5 z&b821Ebt|X8vS$l#vP!u{pOPr`GOkhJ_1{nVLyZm?`! zLrAyS10tD#k?*uD(Su_^CQ4SoC)|QDJdu9fUE1)$N-WgSyBaQ0y33iriD3+T5nQ-q zW!Y>^(Z-Ib^gmb*tW3cT0u91w_`-^sYsi#bd&>*Eg^iJ+z>xZAfkjz{P}T)R<7!|^vFz07(tdK4e;-nriXs_cF-dXpaMg(C?BM-!Tfl2lkZvq7h z-5^H}5OMK7+F7+ya>E`9Y<|dCp?SD#D(mX1xULIB{c^(9Vz~#391u+_BX=hxJ6w^ zucLm1UOOeU;m14PU#LuvMzW0*Ji;Vm->KHLd?LZ4oC@K2lK?~Lvh7)p{|o1yNvG#f z(@*fulsU;f%<_;9{g_1rZCln30mF6>>_hTxAzelG@q0PI+4UDk C<~Ef8 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..15c130930bef1f55810a8dbb3b57605e30ae50d3 GIT binary patch literal 3751 zcmbVPZExE)5dQ98aVdgY!Bn~3hapI3ui26e>6#cw-2sCU2#iJBY-Ld;sTxL_|GvAU z-W+>M)(^HN^4_24j`U#A8w}v|(`5YW&-3f^o72hp8C+a`I1k}$4A<4)h#!E;Es<2tRLoy;&-(g;Q&#yOK^M!inFD^vjL3IeM07PQ!fsoS^t zO{9gSjfW%57~rEbDqBG5rM-mK`SlEqP1H(4ZI_qFcc10V0o&t+clw(&2BAT#cUq@Z zTqB$|FdPP@w028rj$k+f58+It0c9CNx%2(Ie**Oc5HDYHL0GM-5jv_8fvtT^h4$!Y zYrW2hcAJ+i8BUP)_h+gy2Mg(7DeUqOok+}$1FIW``XrMxX{|6D;yF@xDXp?B7LAw zH5wjrEVq4*;Y#GcdY2(28|-#98-dgBt_(_wik$xBX2B8uURr(p%j-8))gBq)OdA6wM|>C4gy4`# z=Yg8-hbIS!ECD}QEhuMwp)JgfUX^yIzUZLlDA1?LG{w^2hL9C0_98XjT3Mb!EpFyU z&T%~QWHrSMyVb_j$1x6Cr)DwERgo*brWH z@$UsrD5@l8f`?F0y3uPqk6Ep%ys&QlW(b5e8~02Q zblW1)047UW^QHcyfz-5klM+aU0;zN-0q+GC-BJq)c>i&HP5)g=hVW&f{+IF>>DD7+ ebkC)!uW0@}?9)M4KDG`qFUk$UyLivTS@tgg7{q%3 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..fe9d9ed251122c2760ea03e1ac647b89c7795a47 GIT binary patch literal 3082 zcmbVOZExC05dO}um{zCYNUpi-rz$C2a~E<-uL)9{ey9+#Hui$IHC}gjU4&Bp`_0VS zU|LLajYPoqygtt};~flog8^K8yqmoEXM8(;e{naygsba!;}9+Mwd!h^LMB!qUVnQ`n(y+Tdm@HR?v~jMB1)8}`6!ELy z<0KQt_IjAkfu4gdWg$ux;pe>9OLAc?Tv#jJjdUUvPW)BnUp|&r8h7b5JoEqt2*rEp zDpObxP{4&qeGa{RRtxpM0WITpDMIEU{m^yT?>+V!cz!9qI7gw6iesle&ZuGmPWvALx7_c*5^ zAc_?HSX#v2!Z(pu(!$*6WgT_Ow@|KgH0YDEr`YW*grdq(HzbmkvaI43Bc+jZ94=O> zsS;F{jY)Gcj;T{gj1!&dlo;|ij!eLxTn$56L+h(-aIMOP5vfFG0xmU#=iTHzNB(J^ zup4hp;>CWNU;ukGalj}MI9xx{8+avAu$qwWtO4ibyfngRUEN;$NsV%x<}0Ltne3@>m&Z ztK5;OyAc>oXFK32IN?sE!O00LIa2mkyYaGg>n1vKfeR_9ljs$0Og7y;Z`fj*uz>FT z5hV9>yK2F)*y!ed)= z)LtLmJ&a%}%B?KgOf^M&sL+bYXasvCV6$`xg@zQY$@iRC6T_tJ}TC3ROy*7P?%Zm|Kc6 zeffQwErqdz0h)85=b$TDib^H;_i8Z63SljrSu5R@bRri{{8AU6KUP*6cj@Hn+-bNU z0E{pl@1?6vX+c0K*D&kXx&_6qXEH(ce%XjD05k~#w^TMk$E3T=<2^^8sS8;>R@W0Kj-jRv56Q>H9o`6IeJZJ^lxXM-KUfNaj4S4LONIq zyZlw>5LvJczsM_CP^IPE_o zPUNtkAG6@X_&9vTl!Wmy{=`1$@iE)I9kRVEQT^nvk0IY+xv6XdPQSIfM0=iWb5)80 zKH(OO;gR(H!z+#vSYl>|!SL`FrMvX}MhrdLis0g#V|H>Y5b*Ncr`lp`r1L=h{U0?4 zqRhd!B@4<~pCt=(qgUbPna>!gISLG^s%MzD+Zf8aK;9F{*UEDBc9>EbImhP0eaX}r zR)$TfW>RcZPGu=}w5868Iez0n<^RkcF)#rr?`eZlT`i2rC3c~R-HqW{KUJP#=~F&2 zDz8CBh;cO+0_*_KmnNC8?hyqd-$Xj#^jnVDE{O-OoWe>}e^5`F&TlrA3>GF4kuHWU zr%sN1HzQr-%t1XOSyzXHc%YXfe3@8ud)}~ubA(Rx`;br-aikbjNF!ml<=oMjQ`t2@&l`o9h&=bq z>}YxrsO;&U9Y}(f(-dUXr12D*FTRD_MZl}u{QBdV5JC5%);R7{hEfDf(@_sx+O$r} zMgtJ~)QbU%DG9xQ9u^zC)$Sd?LZUoQmOTod8|vvh7KR{|o1yNT;VzGj#q= zmpRBhjB=L_{g@>LZJX8(0pqq2Y+FLyvT{#9LBky~jo@Y}Ti&z-I8xN&m?Mx7HB#zM z0>1ke%~CN5c>7^`O;df~2@eD9^!_{Pw(Vka&&g@$C;!q7=?M#;TnD_snF|})-ygwx AjQ{`u literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/asset_metadata_key.dart deleted file mode 100644 index 70186cd41c2aa5d7cd9acc5ad487c230ca34684c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2591 zcmai0ZExC05dO}um`JC_k*K-5PZcetNFjGz+vL=e4=04I#U8_6?OosQItW$y?>Dn+ zJH(I{DaOXL^YY9y&txA#=ez60%|91p z#+Dy4Vcg{R__sF$ew16K4JNrZNiGW#^D&=o3C$(a7$4hDm?5Z1!XT6CY$iA*^05fAWv05Bp179qS84ncszJ)o*f z*h{wqw?n9mexTGcSUrfM#&t~Re9k8><0NuBX=879MWI80B{`{8hNd`3wFUo{(`4Ha ztHg4Hh8^cpi2}Yl(^+~qAM^tnLs^hX^U~>X6J5dNeQ@ecyVrkNt@I|c! z)-`ds-qY-$pr3F00ske)hwy!)bpczW_kyvRKrRZ4!^bO6xwgj!ZC$wYrBk6?yv!kD zmq0x6u)lf*{2WVrtKGb;+@bYJUbTTJ(1#*(E7?@Y%#_~#z&w03I73;X7Ony4tArl+#>R)Iv4xK=bh*2ahaKBj`Fs zC7oQgczJHDS8Tjy$izvxtZUT9aD@eRhO|TPZ;4B5rnWORDKGVc)%Lt!>a)@gHVC0z z8Q3~a(b}Ax99SEeCv;wUin7-tG@ z#@+0sj>Urw;Y2$uMNN0%?XjYy!Xi9dJ>#a8Cn?3^+9UGJ4UwPlA_GK^v(Ym>sgd@6AQu;b4pYF(?Ti>Qf(kob{bj5`FT1DMe5n9 MmFk-pv)y6?0P>9&i~s-t delta 73 zcmew$^;c@cJ4OM=;^Nd2-_(-Cl*E!m@6^i8Ul{9I)X+us(u(qPy^8bmG_rt1bdh>? NYNdMY=4ouV*Z@&^9RvUX diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart index 4b7e6579a12cd3406cf8a3a2c52eda1c9aac34fa..3d247f8572762d371570cec3531431b84e8e3267 100644 GIT binary patch delta 60 zcmeB@{425HIwMQf(kob{bj5`FT1DMe5n9 MmFk0N|h(TL1t6 delta 73 zcmew>(IvUzI-`JNadB#iZ)!? NYNdMY=3A_b*#J7z9Mb>* diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart index c9a7ef4670dafdb5f63c32ac4473063adedf0ba7..cf67b68dd240f9627c7b7950067985560297fce8 100644 GIT binary patch delta 37 tcmeB|Y?9pYl94UAq$o2leewb((an5JKbR+? NYNdMY=5n?iHUL9C96kU5 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7e859ffee..e46f29b50 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2906,6 +2906,112 @@ "x-immich-state": "Stable" } }, + "/assets/metadata": { + "delete": { + "description": "Delete metadata key-value pairs for multiple assets.", + "operationId": "deleteBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + }, + "put": { + "description": "Upsert metadata key-value pairs for multiple assets.", + "operationId": "updateBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upsert asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + } + }, "/assets/random": { "get": { "deprecated": true, @@ -3340,7 +3446,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3399,7 +3505,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -15575,20 +15681,98 @@ ], "type": "string" }, - "AssetMetadataKey": { - "enum": [ - "mobile-app" + "AssetMetadataBulkDeleteDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" ], - "type": "string" + "type": "object" + }, + "AssetMetadataBulkDeleteItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "AssetMetadataBulkResponseDto": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" }, "AssetMetadataResponseDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "updatedAt": { "format": "date-time", @@ -15622,11 +15806,7 @@ "AssetMetadataUpsertItemDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" @@ -20651,11 +20831,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" } }, "required": [ @@ -20670,11 +20846,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5e024b560..560f1077b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -471,7 +471,7 @@ export type AssetBulkDeleteDto = { ids: string[]; }; export type AssetMetadataUpsertItemDto = { - key: AssetMetadataKey; + key: string; value: object; }; export type AssetMediaCreateDto = { @@ -543,6 +543,27 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; +export type AssetMetadataBulkDeleteItemDto = { + assetId: string; + key: string; +}; +export type AssetMetadataBulkDeleteDto = { + items: AssetMetadataBulkDeleteItemDto[]; +}; +export type AssetMetadataBulkUpsertItemDto = { + assetId: string; + key: string; + value: object; +}; +export type AssetMetadataBulkUpsertDto = { + items: AssetMetadataBulkUpsertItemDto[]; +}; +export type AssetMetadataBulkResponseDto = { + assetId: string; + key: string; + updatedAt: string; + value: object; +}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -554,7 +575,7 @@ export type UpdateAssetDto = { visibility?: AssetVisibility; }; export type AssetMetadataResponseDto = { - key: AssetMetadataKey; + key: string; updatedAt: string; value: object; }; @@ -2462,6 +2483,33 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } +/** + * Delete asset metadata + */ +export function deleteBulkAssetMetadata({ assetMetadataBulkDeleteDto }: { + assetMetadataBulkDeleteDto: AssetMetadataBulkDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/assets/metadata", oazapfts.json({ + ...opts, + method: "DELETE", + body: assetMetadataBulkDeleteDto + }))); +} +/** + * Upsert asset metadata + */ +export function updateBulkAssetMetadata({ assetMetadataBulkUpsertDto }: { + assetMetadataBulkUpsertDto: AssetMetadataBulkUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataBulkResponseDto[]; + }>("/assets/metadata", oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataBulkUpsertDto + }))); +} /** * Get random assets */ @@ -2564,7 +2612,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { */ export function deleteAssetMetadata({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { ...opts, @@ -2576,7 +2624,7 @@ export function deleteAssetMetadata({ id, key }: { */ export function getAssetMetadataByKey({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -5363,9 +5411,6 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } -export enum AssetMetadataKey { - MobileApp = "mobile-app" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 649c80e85..56c9d1804 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -79,6 +79,74 @@ describe(AssetController.name, () => { }); }); + describe('PUT /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] }); + expect(status).toBe(200); + }); + }); + + describe('DELETE /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test' }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid() }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] }); + expect(status).toBe(204); + }); + }); + describe('PUT /assets/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/assets/123`); @@ -169,12 +237,10 @@ describe(AssetController.name, () => { it('should require each item to have a valid key', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets/${factory.uuid()}/metadata`) - .send({ items: [{ key: 'someKey' }] }); + .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), - ), + factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']), ); }); @@ -224,16 +290,6 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), - ), - ); - }); }); describe('DELETE /assets/:id/metadata/:key', () => { @@ -247,13 +303,5 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), - ); - }); }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index bcc13fbc0..ba9ec865f 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -7,6 +7,9 @@ import { AssetBulkUpdateDto, AssetCopyDto, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataRouteParams, AssetMetadataUpsertDto, @@ -120,6 +123,32 @@ export class AssetController { return this.service.copy(auth, dto); } + @Put('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Upsert asset metadata', + description: 'Upsert metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + updateBulkAssetMetadata( + @Auth() auth: AuthDto, + @Body() dto: AssetMetadataBulkUpsertDto, + ): Promise { + return this.service.upsertBulkMetadata(auth, dto); + } + + @Delete('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete asset metadata', + description: 'Delete metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise { + return this.service.deleteBulkMetadata(auth, dto); + } + @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) @Endpoint({ diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 03d1e31fb..854c244ba 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -17,9 +17,9 @@ import { ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; +import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -142,8 +142,8 @@ export class AssetMetadataRouteParams { @ValidateUUID() id!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; } export class AssetMetadataUpsertDto { @@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto { } export class AssetMetadataUpsertItemDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; @IsObject() value!: object; } -export class AssetMetadataMobileAppDto { - @IsString() - @Optional() - iCloudId?: string; +export class AssetMetadataBulkUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkUpsertItemDto) + items!: AssetMetadataBulkUpsertItemDto[]; +} + +export class AssetMetadataBulkUpsertItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; + + @IsObject() + value!: object; +} + +export class AssetMetadataBulkDeleteDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkDeleteItemDto) + items!: AssetMetadataBulkDeleteItemDto[]; +} + +export class AssetMetadataBulkDeleteItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; } export class AssetMetadataResponseDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; value!: object; updatedAt!: Date; } +export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto { + assetId!: string; +} + export class AssetCopyDto { @ValidateUUID() sourceId!: string; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d6a557e2c..7f811af37 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, - AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -167,16 +166,14 @@ export class SyncAssetExifV1 { @ExtraModel() export class SyncAssetMetadataV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; value!: object; } @ExtraModel() export class SyncAssetMetadataDeleteV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; } @ExtraModel() diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f25a0798d..27e40139e 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -76,6 +76,14 @@ where "assetId" = $1 and "key" = $2 +-- AssetRepository.deleteBulkMetadata +begin +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 +commit + -- AssetRepository.getByDayOfYear with "res" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7db3a76f1..07e227ea1 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { anyUuid, @@ -256,7 +257,7 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { + upsertMetadata(id: string, items: Array<{ key: string; value: object }>) { return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) @@ -269,8 +270,21 @@ export class AssetRepository { .execute(); } + upsertBulkMetadata(items: Insertable[]) { + return this.db + .insertInto('asset_metadata') + .values(items) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['assetId', 'key', 'value', 'updatedAt']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getMetadataByKey(assetId: string, key: AssetMetadataKey) { + getMetadataByKey(assetId: string, key: string) { return this.db .selectFrom('asset_metadata') .select(['key', 'value', 'updatedAt']) @@ -280,10 +294,23 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + async deleteMetadataByKey(id: string, key: string) { await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); } + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] }) + async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) { + if (items.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + for (const { assetId, key } of items) { + await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute(); + } + }); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 3b94ce6d1..16272eacf 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,4 @@ import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') @@ -11,7 +10,7 @@ export class AssetMetadataAuditTable { assetId!: string; @Column({ index: true }) - key!: AssetMetadataKey; + key!: string; @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) deletedAt!: Generated; diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index d529d6ad7..8a7af1360 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -32,7 +32,7 @@ export class AssetMetadataTable { assetId!: string; @PrimaryColumn({ type: 'character varying' }) - key!: AssetMetadataKey; + key!: AssetMetadataKey | string; @Column({ type: 'jsonb' }) value!: object; diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c584cf134..1e776bd25 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -11,6 +11,9 @@ import { AssetCopyDto, AssetJobName, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataUpsertDto, AssetStatsDto, @@ -19,16 +22,7 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { - AssetFileType, - AssetMetadataKey, - AssetStatus, - AssetVisibility, - JobName, - JobStatus, - Permission, - QueueName, -} from 'src/enum'; +import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -381,12 +375,17 @@ export class AssetService extends BaseService { return this.ocrRepository.getByAssetId(id); } + async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + return this.assetRepository.upsertBulkMetadata(dto.items); + } + async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.upsertMetadata(id, dto.items); } - async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const item = await this.assetRepository.getMetadataByKey(id, key); @@ -396,11 +395,16 @@ export class AssetService extends BaseService { return item; } - async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.deleteMetadataByKey(id, key); } + async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + await this.assetRepository.deleteBulkMetadata(dto.items); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 82ea2cd1f..9cd91a575 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; @@ -179,6 +180,12 @@ export class MediumTestContext { return { asset, result }; } + async newMetadata(dto: Insertable) { + const { assetId, ...item } = dto; + const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]); + return { metadata: dto, result }; + } + async newAssetFile(dto: Insertable) { const result = await this.get(AssetRepository).upsertFile(dto); return { result }; diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 661c4f5cd..d0949c153 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetFileType, JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -430,4 +430,177 @@ describe(AssetService.name, () => { ); }); }); + + describe('upsertBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual( + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }), + ); + }); + + it('should work on conflict', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }); + + // verify existing metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }), + ]); + + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }]; + await sut.upsertBulkMetadata(auth, { items }); + + // verify updated metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }), + ]); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id); + expect(metadata1).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }), + ]); + + const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id); + expect(metadata2).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }), + ]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: AssetMetadataKey.MobileApp, + value: { iCloudId: 'id1' }, + }), + expect.objectContaining({ + key: 'some-other-key', + value: { foo: 'bar' }, + }), + ]), + ); + }); + }); + + describe('deleteBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work even if the item does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]); + await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset.id, key: 'some-other-key' }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]); + }); + + it('should not delete unspecified keys', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }], + }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5ba77ddc2..4847c84a3 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked