diff --git a/CHANGELOG.md b/CHANGELOG.md index a9aad4a0..2d1c3840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ +## [v1.98.1](https://github.com/wanderer-industries/wanderer/compare/v1.98.0...v1.98.1) (2026-04-10) + + + + +### Bug Fixes: + +* signatures: Back linked signatures not cleared old connection statuses + +## [v1.98.0](https://github.com/wanderer-industries/wanderer/compare/v1.97.5...v1.98.0) (2026-04-06) + + + + +### Features: + +* core: added character profile pages support + ## [v1.97.5](https://github.com/wanderer-industries/wanderer/compare/v1.97.4...v1.97.5) (2026-03-26) diff --git a/assets/css/app.css b/assets/css/app.css index b553c4fe..6df4f864 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,5 +1,6 @@ @layer tailwind-base, primereact, tailwind-utilities; +@import 'quill/dist/quill.snow.css'; @import 'primereact/resources/themes/arya-blue/theme.css' layer(primereact); /*@import 'primereact/resources/themes/bootstrap4-dark-blue/theme.css' layer(primereact);*/ @@ -1025,3 +1026,77 @@ body > div:first-of-type { transform: translateY(0); box-shadow: 0 2px 10px rgba(236, 72, 153, 0.3); } + +/* Quill editor dark theme overrides */ +.ql-toolbar.ql-snow { + border-color: #44403c; + background-color: #1c1917; +} + +.ql-container.ql-snow { + border-color: #44403c; + background-color: #0c0a09; + color: #e7e5e4; + min-height: 200px; +} + +.ql-editor.ql-blank::before { + color: #78716c; + font-style: italic; +} + +.ql-snow .ql-stroke { + stroke: #a8a29e; +} + +.ql-snow .ql-fill, +.ql-snow .ql-stroke.ql-fill { + fill: #a8a29e; +} + +.ql-snow .ql-picker { + color: #a8a29e; +} + +.ql-snow .ql-picker-options { + background-color: #1c1917; + border-color: #44403c; +} + +.ql-snow .ql-picker-label:hover, +.ql-snow .ql-picker-label.ql-active, +.ql-snow .ql-picker-item:hover, +.ql-snow .ql-picker-item.ql-selected { + color: #e7e5e4; +} + +.ql-snow .ql-picker-label:hover .ql-stroke, +.ql-snow .ql-picker-label.ql-active .ql-stroke, +.ql-snow button:hover .ql-stroke, +.ql-snow button.ql-active .ql-stroke { + stroke: #e7e5e4; +} + +.ql-snow .ql-picker-label:hover .ql-fill, +.ql-snow .ql-picker-label.ql-active .ql-fill, +.ql-snow button:hover .ql-fill, +.ql-snow button.ql-active .ql-fill { + fill: #e7e5e4; +} + +.ql-snow a { + color: #60a5fa; +} + +.ql-tooltip { + background-color: #1c1917 !important; + border-color: #44403c !important; + color: #e7e5e4 !important; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important; +} + +.ql-tooltip input[type="text"] { + background-color: #0c0a09; + border-color: #44403c; + color: #e7e5e4; +} diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx index 9d07a74b..092de26f 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx @@ -44,17 +44,6 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map switch (group) { case SignatureGroup.Wormhole: - if (values.linked_system) { - await outCommand({ - type: OutCommand.linkSignatureToSystem, - data: { - signature_eve_id: signatureData.eve_id, - solar_system_source: systemId, - solar_system_target: values.linked_system, - }, - }); - } - out = { ...out, custom_info: JSON.stringify({ @@ -125,6 +114,20 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map }, }); + // Link after updateSignatures so the K162's linked_system_id is still nil + // during signature update, preventing maybe_update_connection_* from + // resetting connection properties set by the forward signature. + if (group === SignatureGroup.Wormhole && values.linked_system) { + await outCommand({ + type: OutCommand.linkSignatureToSystem, + data: { + signature_eve_id: signatureData.eve_id, + solar_system_source: systemId, + solar_system_target: values.linked_system, + }, + }); + } + signatureForm.reset(); onHide(); }, diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index e0781201..93e7863f 100755 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -9,6 +9,7 @@ import DownloadJson from './downloadJson'; import NewVersionUpdate from './newVersionUpdate'; import MapAction from './maps/mapAction'; import ShowCharactersAddAlert from './showCharactersAddAlert'; +import WysiwygEditor from './wysiwygEditor'; export default { DownloadJson, @@ -22,4 +23,5 @@ export default { CopyToClipboard, NewVersionUpdate, ShowCharactersAddAlert, + WysiwygEditor, }; diff --git a/assets/js/hooks/wysiwygEditor.ts b/assets/js/hooks/wysiwygEditor.ts new file mode 100644 index 00000000..0ff189eb --- /dev/null +++ b/assets/js/hooks/wysiwygEditor.ts @@ -0,0 +1,46 @@ +import Quill from "quill"; +import TurndownService from "turndown"; + +const WysiwygEditor = { + mounted() { + const view = this as any; + const editorContainer = view.el.querySelector(".ql-editor-container"); + if (!editorContainer) return; + + const toolbarOptions = [ + ["bold", "italic", "underline", "strike"], + ["blockquote", "link"], + [{ list: "ordered" }, { list: "bullet" }], + [{ header: [1, 2, 3, false] }], + ["clean"], + ]; + + const quill = new Quill(editorContainer, { + theme: "snow", + modules: { toolbar: toolbarOptions }, + }); + + const initialContent = editorContainer.getAttribute("data-initial-content"); + if (initialContent) { + quill.clipboard.dangerouslyPasteHTML(initialContent); + } + + quill.on("text-change", () => { + view.pushEvent("content-text-change", { content: quill.getText() }); + }); + + view.handleEvent("request_editor_content", () => { + const html = quill.root.innerHTML; + + if (quill.getText().trim() === "") { + view.pushEvent("editor_content_markdown", { markdown: "" }); + } else { + const turndownService = new TurndownService(); + const markdown = turndownService.turndown(html); + view.pushEvent("editor_content_markdown", { markdown: markdown }); + } + }); + }, +}; + +export default WysiwygEditor; diff --git a/assets/package.json b/assets/package.json index 98386a5c..259c9a24 100644 --- a/assets/package.json +++ b/assets/package.json @@ -43,6 +43,8 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.6", + "quill": "^2.0.3", + "turndown": "^7.2.0", "topbar": "^3.0.0", "use-local-storage-state": "^19.3.1" }, diff --git a/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp b/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp deleted file mode 100644 index 7bd653e5..00000000 Binary files a/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp and /dev/null differ diff --git a/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg b/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg deleted file mode 100644 index cffd0ca8..00000000 Binary files a/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg and /dev/null differ diff --git a/assets/yarn.lock b/assets/yarn.lock index fd94574b..204205a1 100644 --- a/assets/yarn.lock +++ b/assets/yarn.lock @@ -922,6 +922,11 @@ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== +"@mixmark-io/domino@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3" + integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -3000,6 +3005,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -3041,7 +3051,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-diff@^1.1.2: +fast-diff@^1.1.2, fast-diff@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== @@ -4321,11 +4331,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== + lodash.castarray@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q== +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -5146,6 +5166,11 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== +parchment@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/parchment/-/parchment-3.0.0.tgz#2e3a4ada454e1206ae76ea7afcb50e9fb517e7d6" + integrity sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -5451,6 +5476,25 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quill-delta@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-5.1.0.tgz#1c4bc08f7c8e5cc4bdc88a15a1a70c1cc72d2b48" + integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA== + dependencies: + fast-diff "^1.3.0" + lodash.clonedeep "^4.5.0" + lodash.isequal "^4.5.0" + +quill@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/quill/-/quill-2.0.3.tgz#752765a31d5a535cdc5717dc49d4e50099365eb1" + integrity sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw== + dependencies: + eventemitter3 "^5.0.1" + lodash-es "^4.17.21" + parchment "^3.0.0" + quill-delta "^5.1.0" + react-dom@18.3.1, react-dom@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" @@ -5987,16 +6031,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6081,14 +6116,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6296,6 +6324,13 @@ ts-jest@^29.1.2: type-fest "^4.41.0" yargs-parser "^21.1.1" +turndown@^7.2.0: + version "7.2.4" + resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.2.4.tgz#42d98202aefa8c188c997b586bc6da78bdf27ea2" + integrity sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ== + dependencies: + "@mixmark-io/domino" "^2.2.0" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -6618,16 +6653,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/lib/wanderer_app/api/character.ex b/lib/wanderer_app/api/character.ex index 239b753f..03b6064d 100644 --- a/lib/wanderer_app/api/character.ex +++ b/lib/wanderer_app/api/character.ex @@ -41,6 +41,7 @@ defmodule WandererApp.Api.Character do ) define(:admin_all, action: :admin_all) + define(:update_description, action: :update_description) end actions do @@ -141,6 +142,11 @@ defmodule WandererApp.Api.Character do accept([:eve_wallet_balance]) end + + update :update_description do + accept([:description]) + require_atomic? false + end end cloak do @@ -211,6 +217,11 @@ defmodule WandererApp.Api.Character do attribute :eve_wallet_balance, :float attribute :tracking_pool, :string + attribute :description, :string do + allow_nil? true + constraints max_length: 10_000 + end + create_timestamp(:inserted_at) update_timestamp(:updated_at) end diff --git a/lib/wanderer_app/api/map_transaction.ex b/lib/wanderer_app/api/map_transaction.ex index ca6928f1..1cb96884 100644 --- a/lib/wanderer_app/api/map_transaction.ex +++ b/lib/wanderer_app/api/map_transaction.ex @@ -22,6 +22,7 @@ defmodule WandererApp.Api.MapTransaction do define(:by_user, action: :by_user) define(:create, action: :create) define(:top_donators, action: :top_donators) + define(:server_top_donators, action: :server_top_donators) end actions do @@ -77,6 +78,31 @@ defmodule WandererApp.Api.MapTransaction do |> then(&{:ok, &1}) end end + + action :server_top_donators, {:array, :struct} do + argument(:after, :utc_datetime, allow_nil?: true) + + run fn input, _context -> + base = + from(t in __MODULE__, + where: t.type == :in and not is_nil(t.user_id), + group_by: [t.user_id], + select: %{user_id: t.user_id, total_amount: sum(t.amount)}, + order_by: [desc: sum(t.amount)], + limit: 10 + ) + + query = + case input.arguments[:after] do + nil -> base + after_date -> base |> where([t], t.inserted_at >= ^after_date) + end + + query + |> WandererApp.Repo.all() + |> then(&{:ok, &1}) + end + end end attributes do diff --git a/lib/wanderer_app/map/operations/signatures.ex b/lib/wanderer_app/map/operations/signatures.ex index c2917c02..b8828d33 100644 --- a/lib/wanderer_app/map/operations/signatures.ex +++ b/lib/wanderer_app/map/operations/signatures.ex @@ -552,15 +552,9 @@ defmodule WandererApp.Map.Operations.Signatures do if not is_nil(forward_sig.custom_info) do case Jason.decode(forward_sig.custom_info) do {:ok, decoded} -> - fwd_time = - if is_nil(signature_time_status), - do: Map.get(decoded, "time_status"), - else: signature_time_status - - fwd_mass = - if is_nil(signature_mass_status), - do: Map.get(decoded, "mass_status"), - else: signature_mass_status + # Always prefer forward sig values over K162 defaults + fwd_time = Map.get(decoded, "time_status") || signature_time_status + fwd_mass = Map.get(decoded, "mass_status") || signature_mass_status {fwd_time, fwd_mass} @@ -600,6 +594,29 @@ defmodule WandererApp.Map.Operations.Signatures do mass_status: signature_mass_status }) end + + # Update K162's custom_info to match the resolved connection values + if not is_nil(signature_time_status) or not is_nil(signature_mass_status) do + updated_custom_info = + (signature.custom_info || "{}") + |> Jason.decode!() + |> then(fn decoded -> + decoded + |> then(fn d -> + if not is_nil(signature_time_status), + do: Map.put(d, "time_status", signature_time_status), + else: d + end) + |> then(fn d -> + if not is_nil(signature_mass_status), + do: Map.put(d, "mass_status", signature_mass_status), + else: d + end) + end) + |> Jason.encode!() + + MapSystemSignature.update(signature, %{custom_info: updated_custom_info}) + end end # Broadcast update diff --git a/lib/wanderer_app_web.ex b/lib/wanderer_app_web.ex index 084b5be6..858b4ff4 100644 --- a/lib/wanderer_app_web.ex +++ b/lib/wanderer_app_web.ex @@ -68,6 +68,13 @@ defmodule WandererAppWeb do end end + def blog_live_view do + live_view( + layout: {WandererAppWeb.Layouts, :blog}, + container: {:div, class: ""} + ) + end + def live_component do quote do use Phoenix.LiveComponent diff --git a/lib/wanderer_app_web/components/layouts.ex b/lib/wanderer_app_web/components/layouts.ex index c8dd2199..7de56f13 100644 --- a/lib/wanderer_app_web/components/layouts.ex +++ b/lib/wanderer_app_web/components/layouts.ex @@ -206,6 +206,7 @@ defmodule WandererAppWeb.Layouts do >
  • Changelog
  • News
  • +
  • Sponsors
  • License
  • Contact Us
  • @@ -236,6 +237,13 @@ defmodule WandererAppWeb.Layouts do icon="hero-signal-solid" tip="Characters Tracking" /> + <.nav_link + :if={@map_subscriptions_enabled} + href="/sponsors" + active={@active_tab == :sponsors} + icon="hero-heart-solid" + tip="Our Sponsors" + />
    diff --git a/lib/wanderer_app_web/live/character_profile/character_profile_live.ex b/lib/wanderer_app_web/live/character_profile/character_profile_live.ex new file mode 100644 index 00000000..837ca470 --- /dev/null +++ b/lib/wanderer_app_web/live/character_profile/character_profile_live.ex @@ -0,0 +1,139 @@ +defmodule WandererAppWeb.CharacterProfileLive do + use WandererAppWeb, :live_view + + require Logger + + @impl true + def mount(%{"eve_id" => eve_id_str}, _session, socket) do + case Integer.parse(eve_id_str) do + {eve_id, ""} -> + case load_character(eve_id) do + {:ok, character} -> + is_owner = owner?(socket.assigns.current_user, eve_id_str) + description_html = render_description(character.description) + + {:ok, + assign(socket, + page_title: character.name, + profile: build_profile(character), + is_owner: is_owner, + editing: false, + description_html: description_html, + description_raw: character.description || "" + )} + + {:error, _} -> + {:ok, + socket + |> put_flash(:error, "Character not found") + |> redirect(to: "/")} + end + + _ -> + {:ok, + socket + |> put_flash(:error, "Invalid character ID") + |> redirect(to: "/")} + end + end + + @impl true + def handle_event("toggle_edit", _params, socket) do + if socket.assigns.is_owner do + {:noreply, assign(socket, editing: !socket.assigns.editing)} + else + {:noreply, socket} + end + end + + def handle_event("save_description", _params, socket) do + if socket.assigns.is_owner do + {:noreply, push_event(socket, "request_editor_content", %{})} + else + {:noreply, socket} + end + end + + def handle_event("cancel_edit", _params, socket) do + {:noreply, assign(socket, editing: false)} + end + + def handle_event("content-text-change", _params, socket) do + {:noreply, socket} + end + + def handle_event("editor_content_markdown", %{"markdown" => markdown}, socket) do + if socket.assigns.is_owner do + markdown = String.slice(markdown, 0, 10_000) + eve_id_str = socket.assigns.profile.eve_id + + case WandererApp.Api.Character.by_eve_id(eve_id_str) do + {:ok, character} -> + case WandererApp.Api.Character.update_description(character, %{description: markdown}) do + {:ok, _updated} -> + Cachex.del(:api_cache, "character_profile_#{eve_id_str}") + description_html = render_description(markdown) + + {:noreply, + socket + |> assign( + editing: false, + description_html: description_html, + description_raw: markdown + ) + |> put_flash(:info, "Description updated")} + + {:error, reason} -> + Logger.error("Failed to update description: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Failed to save description")} + end + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Character not found")} + end + else + {:noreply, socket} + end + end + + defp owner?(current_user, eve_id_str) do + case current_user do + %{characters: characters} when is_list(characters) -> + Enum.any?(characters, fn c -> to_string(c.eve_id) == eve_id_str end) + + _ -> + false + end + end + + defp load_character(eve_id) do + WandererApp.Api.Character.by_eve_id(eve_id) + end + + defp build_profile(character) do + %{ + eve_id: character.eve_id, + name: character.name, + corporation_id: character.corporation_id, + corporation_name: character.corporation_name, + corporation_ticker: character.corporation_ticker, + alliance_id: character.alliance_id, + alliance_name: character.alliance_name, + alliance_ticker: character.alliance_ticker, + online: character.online + } + end + + defp render_description(nil), do: "" + defp render_description(""), do: "" + + defp render_description(markdown) do + case Earmark.as_html(markdown) do + {:ok, html, _} -> + HtmlSanitizeEx.markdown_html(html) + + {:error, _, _} -> + "" + end + end +end diff --git a/lib/wanderer_app_web/live/character_profile/character_profile_live.html.heex b/lib/wanderer_app_web/live/character_profile/character_profile_live.html.heex new file mode 100644 index 00000000..fe954bb6 --- /dev/null +++ b/lib/wanderer_app_web/live/character_profile/character_profile_live.html.heex @@ -0,0 +1,133 @@ +
    +
    +
    +
    + {@profile.name} +
    +
    +

    {@profile.name}

    + + +
    +
    + {@profile.corporation_name} + + + [{@profile.corporation_ticker}] + + {@profile.corporation_name} + +
    +
    + {@profile.alliance_name} + + + [{@profile.alliance_ticker}] + + {@profile.alliance_name} + +
    +
    + +
    + + <%!-- About / Description section --%> +
    +
    +

    About

    + +
    + + <%!-- View mode --%> +
    + <%= if @description_html != "" do %> +
    + {raw(@description_html)} +
    + <% else %> +

    No description yet.

    + <% end %> +
    + + <%!-- Edit mode --%> +
    +
    +
    +
    +
    + <.button + type="button" + phx-click="save_description" + class="p-button p-component p-button-primary w-full" + style="min-width: 0;" + > + Save + + <.button + type="button" + phx-click="cancel_edit" + class="p-button p-component p-button-primary w-full" + style="min-width: 0;" + > + Cancel + +
    +
    +
    +
    +
    +
    diff --git a/lib/wanderer_app_web/live/characters/characters_live.html.heex b/lib/wanderer_app_web/live/characters/characters_live.html.heex index 288d8757..2e6efec7 100644 --- a/lib/wanderer_app_web/live/characters/characters_live.html.heex +++ b/lib/wanderer_app_web/live/characters/characters_live.html.heex @@ -197,6 +197,13 @@
    + <.link + navigate={~p"/characters/#{character.eve_id}"} + class="tooltip tooltip-bottom" + data-tip="View Profile" + > + <.icon name="hero-user-solid" class="w-4 h-4 hover:text-white" /> + <.link patch={~p"/characters/authorize"} class="tooltip tooltip-bottom" @@ -236,7 +243,9 @@ <:col :let={character} label="Name"> - {character.name} + <.link navigate={~p"/characters/#{character.eve_id}"} class="hover:text-white underline"> + {character.name} + <:col :let={character} label="Corporation"> {character diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex index 5d6fee64..c95ee4e4 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex @@ -326,15 +326,9 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do if not is_nil(forward_sig.custom_info) do decoded = forward_sig.custom_info |> Jason.decode!() - fwd_time = - if is_nil(signature_time_status), - do: Map.get(decoded, "time_status"), - else: signature_time_status - - fwd_mass = - if is_nil(signature_mass_status), - do: Map.get(decoded, "mass_status"), - else: signature_mass_status + # Always prefer forward sig values over K162 defaults + fwd_time = Map.get(decoded, "time_status") || signature_time_status + fwd_mass = Map.get(decoded, "mass_status") || signature_mass_status {fwd_time, fwd_mass} else @@ -373,6 +367,29 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do mass_status: signature_mass_status }) end + + # Update K162's custom_info to match the resolved connection values + if not is_nil(signature_time_status) or not is_nil(signature_mass_status) do + updated_custom_info = + (signature.custom_info || "{}") + |> Jason.decode!() + |> then(fn decoded -> + decoded + |> then(fn d -> + if not is_nil(signature_time_status), + do: Map.put(d, "time_status", signature_time_status), + else: d + end) + |> then(fn d -> + if not is_nil(signature_mass_status), + do: Map.put(d, "mass_status", signature_mass_status), + else: d + end) + end) + |> Jason.encode!() + + WandererApp.Api.MapSystemSignature.update(signature, %{custom_info: updated_custom_info}) + end end WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source) diff --git a/lib/wanderer_app_web/live/nav.ex b/lib/wanderer_app_web/live/nav.ex index a1a28ed5..aec602dc 100755 --- a/lib/wanderer_app_web/live/nav.ex +++ b/lib/wanderer_app_web/live/nav.ex @@ -6,6 +6,7 @@ defmodule WandererAppWeb.Nav do alias WandererAppWeb.{ AccessListsLive, + CharacterProfileLive, MapLive, MapsLive, CharactersLive, @@ -64,6 +65,9 @@ defmodule WandererAppWeb.Nav do {CharactersLive, _} -> :characters + {CharacterProfileLive, _} -> + :characters + {CharactersTrackingLive, _} -> :characters_tracking diff --git a/lib/wanderer_app_web/live/sponsors/sponsors_live.ex b/lib/wanderer_app_web/live/sponsors/sponsors_live.ex new file mode 100644 index 00000000..ebd454d4 --- /dev/null +++ b/lib/wanderer_app_web/live/sponsors/sponsors_live.ex @@ -0,0 +1,93 @@ +defmodule WandererAppWeb.SponsorsLive do + use WandererAppWeb, :live_view + + alias BetterNumber, as: Number + + require Logger + + @cache_key "server_top_donators" + @cache_ttl :timer.minutes(15) + + @impl true + def mount(_params, _session, socket) do + if not WandererApp.Env.map_subscriptions_enabled?() do + {:ok, socket |> redirect(to: "/")} + else + top_donators = load_top_donators() + {corporation_id, corporation_info} = load_corporation_info() + + {:ok, + assign(socket, + page_title: "Sponsors", + top_donators: top_donators, + corporation_id: corporation_id, + corporation_info: corporation_info + )} + end + end + + def format_isk(amount) do + Number.to_human(amount, units: ["", "K", "M", "B", "T", "P"]) + end + + defp load_top_donators do + case Cachex.get(:api_cache, @cache_key) do + {:ok, nil} -> + donators = fetch_and_enrich() + Cachex.put(:api_cache, @cache_key, donators, ttl: @cache_ttl) + donators + + {:ok, cached} -> + cached + + _ -> + fetch_and_enrich() + end + end + + defp fetch_and_enrich do + after_date = DateTime.utc_now() |> DateTime.add(-30, :day) + + case WandererApp.Api.MapTransaction.server_top_donators(%{after: after_date}) do + {:ok, donators} -> + enrich_with_characters(donators) + + {:error, reason} -> + Logger.warning("Failed to load server top donators: #{inspect(reason)}") + [] + end + end + + defp enrich_with_characters(donators) do + donators + |> Enum.map(fn %{user_id: user_id, total_amount: total_amount} -> + case WandererApp.Api.Character.active_by_user(%{user_id: user_id}) do + {:ok, [character | _]} -> + %{ + character_name: character.name, + eve_id: character.eve_id, + corporation_name: character.corporation_name, + corporation_ticker: character.corporation_ticker, + total_amount: total_amount + } + + _ -> + nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp load_corporation_info do + corp_eve_id = WandererApp.Env.corp_eve_id() + + if corp_eve_id == -1 do + {nil, nil} + else + case WandererApp.Esi.get_corporation_info(corp_eve_id) do + {:ok, info} -> {corp_eve_id, info} + _ -> {nil, nil} + end + end + end +end diff --git a/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex b/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex new file mode 100644 index 00000000..74ae3fbe --- /dev/null +++ b/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex @@ -0,0 +1,119 @@ +
    +
    +

    + Our Sponsors +

    +

    + Top 10 ISK donators across all maps in the last 30 days. +

    + +
    +
    + No donations found for this period. +
    + +
    +
    + + {index + 1} + + {donator.character_name} +
    + <.link + navigate={~p"/characters/#{donator.eve_id}"} + class="text-sm font-bold text-white truncate hover:text-blue-400 transition-colors" + > + {donator.character_name} + +
    + + [{donator.corporation_ticker}] + {donator.corporation_name} + +
    +
    +
    + ISK {WandererAppWeb.SponsorsLive.format_isk(donator.total_amount)} +
    +
    +
    + +
    + <.button + type="button" + phx-click={show_modal("donate-modal")} + class="p-button p-component p-button-primary w-full" + style="min-width: 0;" + > + Become a Sponsor + +
    +
    +
    + + <.modal id="donate-modal" title="How to become a sponsor?" class="!w-[700px]"> +
    + It's not available yet :( +
    +
    +
    +
    + Wanderer EVE Corporation: +
    +
    +
    + {@corporation_info["name"]} +
    +
    +   {@corporation_info["name"]} +
    +
    +

    + How to Donate ISK to Wanderer in Eve Online +

    +
      +
    1. + Open corporations overview: + Click on the 'Social' and then on 'Corporation' in the Neocom menu to access corporations search. +
    2. +
    3. + Search for a Corporation: + Type in the search bar the name: {@corporation_info["name"]}. +
    4. +
    5. + Choose 'Give Money': + Select the 'Give Money' in the context menu to initiate the transfer. +
    6. +
    7. + Specify the Amount: + Input the amount of ISK you wish to transfer to the corporate account. +
    8. +
    9. + Add a Reason (Optional): + Include a short note or reason for the transfer if desired. +
    10. +
    11. + Confirm the Transfer: + Double-check the recipient's name and the amount, then click 'OK' to complete the transaction. +
    12. +
    +

    + The ISK will be transferred instantly to the Wanderer's account. Ensure you enter the correct recipient name to avoid any errors. Fly safe and enjoy your time in Eve Online! +

    +
    +
    + +
    diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index 919c0d84..4eab48b9 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -7,7 +7,7 @@ defmodule WandererAppWeb.Router do import WandererAppWeb.UserAuth, warn: false, - only: [redirect_if_user_is_authenticated: 2] + only: [redirect_if_user_is_authenticated: 2, require_authenticated_user: 2] import WandererAppWeb.BasicAuth, warn: false, @@ -164,6 +164,10 @@ defmodule WandererAppWeb.Router do plug :put_layout, html: {WandererAppWeb.Layouts, :blog} end + pipeline :require_auth do + plug :require_authenticated_user + end + pipeline :api do plug WandererAppWeb.Plugs.ContentNegotiation, accepts: ["json"] plug :accepts, ["json"] @@ -417,6 +421,8 @@ defmodule WandererAppWeb.Router do get "/", BlogController, :license end + + scope "/swaggerui" do pipe_through [:browser, :api_spec] @@ -549,6 +555,8 @@ defmodule WandererAppWeb.Router do live "/tracking", CharactersTrackingLive, :index live "/characters", CharactersLive, :index live "/characters/authorize", CharactersLive, :authorize + live "/characters/:eve_id", CharacterProfileLive, :show + live "/sponsors", SponsorsLive, :index live "/maps/new", MapsLive, :create live "/maps/:slug/edit", MapsLive, :edit live "/maps/:slug/settings", MapsLive, :settings diff --git a/mix.exs b/mix.exs index fca14304..1105237b 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do @source_url "https://github.com/wanderer-industries/wanderer" - @version "1.97.5" + @version "1.98.1" def project do [ @@ -134,6 +134,8 @@ defmodule WandererApp.MixProject do {:live_view_events, "~> 0.1.0"}, {:ash_pagify, "~> 1.4.1"}, {:timex, "~> 3.0"}, + {:earmark, "~> 1.4"}, + {:html_sanitize_ex, "~> 1.4"}, # Test coverage and quality {:excoveralls, "~> 0.18", only: :test} ] diff --git a/mix.lock b/mix.lock index 59319aad..87e4410e 100644 --- a/mix.lock +++ b/mix.lock @@ -57,6 +57,7 @@ "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heroicons": {:hex, :heroicons, "0.5.5", "c2bcb05a90f010df246a5a2a2b54cac15483b5de137b2ef0bead77fcdf06e21a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "2f4bf929440fecd5191ba9f40e5009b0f75dc993d765c0e4d068fcb7026d6da1"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.5.0", "ea13a4a92ba0fa17bc6199f1bb7b755a8595ec3b5f763330ea8570d8b5f648e4", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "4eaa2205ae56fab95d0f25065d709b05f0cba730f3fcec184dfde594acdd4578"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, @@ -78,6 +79,7 @@ "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, "mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"}, + "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, "nebulex": {:hex, :nebulex, "2.6.2", "0874989db4e382362884662d2ee9f31b4c4862595f4ec300bd279068729dd2d0", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:shards, "~> 1.1", [hex: :shards, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "002a1774d5a187eb631ae4006db13df4bb6b325fe2a3c14cb14a1f3e989042b4"}, "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, diff --git a/priv/posts/2026/01-01-roadmap-2026.md b/priv/posts/2026/01-01-roadmap-2026.md index 602ba019..e4956e1d 100644 --- a/priv/posts/2026/01-01-roadmap-2026.md +++ b/priv/posts/2026/01-01-roadmap-2026.md @@ -3,7 +3,7 @@ title: "Event: Wanderer 2026 Roadmap Reveal", author: "Wanderer Team", cover_image_uri: "/images/news/2026/01-01-roadmap/cover.webp", tags: ~w(event roadmap 2026 announcement community), -description: "JWanderer's 2026 roadmap are ready to reveal! Discover what exciting features and improvements are coming in 2026." +description: "Wanderer's 2026 roadmap are ready to reveal! Discover what exciting features and improvements are coming in 2026." } --- diff --git a/priv/posts/2026/01-05-weekly-giveaway-challenge.md b/priv/posts/2026/01-05-weekly-giveaway-challenge.md deleted file mode 100644 index 8ca30415..00000000 --- a/priv/posts/2026/01-05-weekly-giveaway-challenge.md +++ /dev/null @@ -1,36 +0,0 @@ -%{ -title: "Event: Weekly Giveaway Challenge", -author: "Wanderer Team", -cover_image_uri: "/images/news/2026/01-05-weekly-giveaway/cover.webp", -tags: ~w(event giveaway challenge), -description: "Join our Weekly Giveaway Challenge! Be the fastest to claim your reward!" -} - ---- - - -![Weekly Giveaway Challenge](/images/news/2026/01-05-weekly-giveaway/cover.webp "Weekly Giveaway Challenge") - -### Event Details - -In 2026, we're going to giveaway partnership SKIN codes for our community, every week! - -- **Event Name:** Weekly Giveaway Challenge -- **Event Link:** [Join Weekly Giveaway Challenge](https://eventcortex.com/events/invite/Cjo87svZFq6J8cc1cubH4B7AR_VfPmQ4) - ---- - -### Tips for Participants - -- **Be Ready:** Know the reveal time and be online a few minutes early. - ---- - -Good luck, and may the fastest capsuleer win! - ---- - -Fly safe, -**Wanderer Team** - ---- diff --git a/priv/posts/2026/02-12-eve-creator-awards.md b/priv/posts/2026/02-12-eve-creator-awards.md deleted file mode 100644 index 10691c51..00000000 --- a/priv/posts/2026/02-12-eve-creator-awards.md +++ /dev/null @@ -1,53 +0,0 @@ -%{ -title: "EVE Creator Awards - Nominate Wanderer!", -author: "Wanderer Team", -cover_image_uri: "/images/news/2026/02-12-eve-creator-awards/cover.jpg", -tags: ~w(event community awards nomination), -description: "CCP has opened nominations for the EVE Creator Awards! Support Wanderer by voting for Third-Party App of the Year and Developer of the Year." -} - ---- - -![EVE Creator Awards](/images/news/2026/02-12-eve-creator-awards/cover.jpg "EVE Creator Awards") - -### EVE Creator Awards - We Need Your Vote! - -CCP has opened nominations for the **EVE Creator Awards**, including **Best Third-Party App** and **Developer of the Year**, and you can support us by voting. - ---- - -### How to Nominate Us - -You can nominate us for **Third-Party App of the Year**, and choose one of the team as **Developer of the Year**: Dan Sylvest, vvrong, or Gustav Oswaldo. - -**App field** may be filled with: `Wanderer` / `https://wanderer.ltd/` - ---- - -### A Bit of Stats - -Over the past months, Wanderer has grown to more than **7,000 monthly users**, with pilots joining from all around the world. - ---- - -### Meet the Team - -- **Dan Sylvest** — leads frontend, design, and frontend architecture, along with several supporting services. -- **vvrong** (you know him as Demiro) — responsible for backend development, core architecture, and APIs, with additional frontend contributions. -- **Gustav Oswaldo** — contributes across backend and frontend, including zKillboard-related services, APIs, and bots. - ---- - -### Vote Now - -- **[Vote for us here](https://eve-creator-awards.paperform.co/)** -- **[Read the announcement](https://www.eveonline.com/news/view/eve-creator-awards)** - ---- - -Thank you for your support! - -Fly safe, -**Wanderer Team** - ---- diff --git a/priv/repo/migrations/20260406213852_add_character_description.exs b/priv/repo/migrations/20260406213852_add_character_description.exs new file mode 100644 index 00000000..6454b8ff --- /dev/null +++ b/priv/repo/migrations/20260406213852_add_character_description.exs @@ -0,0 +1,29 @@ +defmodule WandererApp.Repo.Migrations.AddCharacterDescription do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: '{wormholes}' + end + + alter table(:character_v1) do + add :description, :text + end + end + + def down do + alter table(:character_v1) do + remove :description + end + + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: nil + end + end +end diff --git a/priv/repo/seeds_sponsors.exs b/priv/repo/seeds_sponsors.exs new file mode 100644 index 00000000..fbb1b55e --- /dev/null +++ b/priv/repo/seeds_sponsors.exs @@ -0,0 +1,135 @@ +# Seed script for example sponsor donations. +# +# Creates sample users, characters, a map, and donation transactions +# so the /sponsors page and /characters/:eve_id profile pages have data. +# +# Run with: +# mix run priv/repo/seeds_sponsors.exs +# +require Logger +Logger.info("Seeding sponsor donation data...") + +alias WandererApp.Repo + +# Well-known EVE character data (real public info, no secrets) +characters_data = [ + %{ + eve_id: "96734492", + name: "Oz Hasaki", + corporation_id: 98_553_333, + corporation_name: "Wormhole Wanderers", + corporation_ticker: "W.W", + alliance_id: nil, + alliance_name: nil, + alliance_ticker: nil + }, + %{ + eve_id: "2119543215", + name: "Katya Itzimansen", + corporation_id: 98_681_432, + corporation_name: "Anoikis Explorers", + corporation_ticker: "A.EXP", + alliance_id: 99_011_258, + alliance_name: "Anoikis Coalition", + alliance_ticker: "ANOK" + }, + %{ + eve_id: "93568202", + name: "Dmitriy Lancel", + corporation_id: 98_712_045, + corporation_name: "Signal Cartel", + corporation_ticker: "1420.", + alliance_id: 99_005_338, + alliance_name: "EvE-Scout Enclave", + alliance_ticker: "SCOUT" + }, + %{ + eve_id: "94801715", + name: "Heron Explorer", + corporation_id: 98_553_333, + corporation_name: "Wormhole Wanderers", + corporation_ticker: "W.W", + alliance_id: nil, + alliance_name: nil, + alliance_ticker: nil + } +] + +# Donation amounts (ISK) for each character — descending order +donation_amounts = [ + 2_500_000_000.0, + 1_200_000_000.0, + 800_000_000.0, + 350_000_000.0 +] + +# ---------- Create users, characters, a map, and transactions ---------- + +Repo.transaction(fn -> + # 1. Create a dummy map for the transactions (needs name + slug) + {:ok, map} = + WandererApp.Api.Map.new(%{ + name: "Seed Sponsors Map", + slug: "seed-sponsors-map" + }) + + Logger.info(" Created map: #{map.id}") + + Enum.zip(characters_data, donation_amounts) + |> Enum.each(fn {char_data, amount} -> + # 2. Create a user + {:ok, user} = + WandererApp.Api.User + |> Ash.Changeset.for_create(:create, %{ + name: char_data.name, + hash: "seed_sponsor_#{char_data.eve_id}" + }) + |> Ash.create() + + Logger.info(" Created user: #{user.id} (#{user.name})") + + # 3. Create a character linked to that user + {:ok, character} = + WandererApp.Api.Character + |> Ash.Changeset.for_create(:create, %{ + eve_id: char_data.eve_id, + name: char_data.name + }) + |> Ash.create() + + # Assign user + {:ok, character} = WandererApp.Api.Character.assign_user(character, %{user_id: user.id}) + + # Update corporation info + {:ok, character} = + WandererApp.Api.Character.update_corporation(character, %{ + corporation_id: char_data.corporation_id, + corporation_name: char_data.corporation_name, + corporation_ticker: char_data.corporation_ticker + }) + + # Update alliance info (if present) + if char_data.alliance_id do + WandererApp.Api.Character.update_alliance(character, %{ + alliance_id: char_data.alliance_id, + alliance_name: char_data.alliance_name, + alliance_ticker: char_data.alliance_ticker + }) + end + + Logger.info(" Created character: #{character.eve_id} (#{character.name})") + + # 4. Create a donation transaction (type: :in) + {:ok, _txn} = + WandererApp.Api.MapTransaction.create(%{ + map_id: map.id, + user_id: user.id, + type: :in, + amount: amount + }) + + Logger.info(" Created donation: #{amount} ISK from #{char_data.name}") + end) +end) + +Logger.info("Sponsor seed data complete!") diff --git a/priv/resource_snapshots/repo/character_v1/20260406213852.json b/priv/resource_snapshots/repo/character_v1/20260406213852.json new file mode 100644 index 00000000..79f95921 --- /dev/null +++ b/priv/resource_snapshots/repo/character_v1/20260406213852.json @@ -0,0 +1,413 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "online", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scopes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "character_owner_hash", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "token_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ship_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ship_item_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "corporation_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "corporation_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "corporation_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "alliance_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "alliance_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "alliance_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tracking_pool", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "character_v1_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "user_v1" + }, + "scale": null, + "size": null, + "source": "user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_eve_wallet_balance", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_location", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_ship", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_solar_system_id", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_structure_id", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_station_id", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_access_token", + "type": "binary" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_refresh_token", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "B75AEDD6CAB8418E22082401EEB78FDA9E2B9B70883EC7EA0E05EE26695D138E", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "character_v1_unique_eve_id_index", + "keys": [ + { + "type": "atom", + "value": "eve_id" + } + ], + "name": "unique_eve_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "character_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/maps_v1/20260406213852.json b/priv/resource_snapshots/repo/maps_v1/20260406213852.json new file mode 100644 index 00000000..d436d79d --- /dev/null +++ b/priv/resource_snapshots/repo/maps_v1/20260406213852.json @@ -0,0 +1,277 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "personal_note", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "public_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "\"wormholes\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scope", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "only_tracked_characters", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "webhooks_enabled", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sse_enabled", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "'{wormholes}'", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scopes", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_owner_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "owner_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "17E507A2F8B57193D92DF2E707C6623C68A07B5058227A12DEF1522777BE7B83", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_public_api_key_index", + "keys": [ + { + "type": "atom", + "value": "public_api_key" + } + ], + "name": "unique_public_api_key", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "maps_v1" +} \ No newline at end of file diff --git a/test/unit/map/server/k162_backlink_preference_test.exs b/test/unit/map/server/k162_backlink_preference_test.exs new file mode 100644 index 00000000..9926725e --- /dev/null +++ b/test/unit/map/server/k162_backlink_preference_test.exs @@ -0,0 +1,359 @@ +defmodule WandererApp.Map.Server.K162BacklinkPreferenceTest do + @moduledoc """ + Tests for the K162 back-link preference fix. + + Verifies that when a K162 (return wormhole) signature is linked to a system + where a forward signature (e.g., H296) already set mass/time on the connection, + the forward sig values take precedence over K162 form defaults. + + Also verifies that the K162's custom_info is updated to reflect resolved values. + """ + use WandererApp.DataCase, async: false + + import Mox + + alias WandererApp.Api.{MapSystem, MapSystemSignature} + alias WandererApp.Map.Server.SignaturesImpl + alias WandererAppWeb.Factory + + setup :verify_on_exit! + + setup do + Mox.set_mox_global() + + # Setup DDRT mocks + Test.DDRTMock + |> stub(:init_tree, fn _name, _opts -> :ok end) + |> stub(:insert, fn _data, _tree_name -> {:ok, %{}} end) + |> stub(:update, fn _id, _data, _tree_name -> {:ok, %{}} end) + |> stub(:delete, fn _ids, _tree_name -> {:ok, %{}} end) + |> stub(:query, fn _bbox, _tree_name -> {:ok, []} end) + + # Setup CachedInfo mocks + WandererApp.CachedInfo.Mock + |> stub(:get_system_static_info, fn + 30_000_142 -> + {:ok, + %{ + solar_system_id: 30_000_142, + solar_system_name: "Jita", + system_class: 7, + security: "0.9" + }} + + 30_000_143 -> + {:ok, + %{ + solar_system_id: 30_000_143, + solar_system_name: "Perimeter", + system_class: 7, + security: "0.9" + }} + + _ -> + {:error, :not_found} + end) + + character = Factory.create_character() + map = Factory.create_map(%{owner_id: character.id}) + + %{map: map, character: character} + end + + describe "find_forward_signature/2" do + test "finds forward signature in target system that links back to source", %{map: map} do + # System A (source) at solar_system_id 30_000_142 + {:ok, system_a} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"}) + + # System B (target) at solar_system_id 30_000_143 + {:ok, system_b} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_143, name: "System B"}) + + # Forward sig in System A: H296 linking to System B (solar_system_id) + _forward_sig = + Factory.insert(:map_system_signature, %{ + system_id: system_a.id, + eve_id: "FWD-001", + type: "H296", + group: "Wormhole", + linked_system_id: 30_000_143, + custom_info: + Jason.encode!(%{"time_status" => 1, "mass_status" => 1, "k162Type" => nil}) + }) + + # find_forward_signature looks in the target system (system_b.id) + # for a signature linking back to the source solar_system_id (30_000_142) + # But the forward sig is in system_a, linking TO system_b. + # So we need to call it with system_a.id (where the forward sig lives) + # and source_solar_system_id = 30_000_143 (what the forward sig links to) + # + # Wait - re-reading the function: + # find_forward_signature(target_system_uuid, source_solar_system_id) + # It looks in target_system_uuid for sigs with linked_system_id == source_solar_system_id + # + # In the K162 linking scenario: + # - K162 is in System B, user links it to System A + # - We call find_forward_signature(target_system.id = System A, source_solar_system = System B's solar_system_id) + # - This finds the H296 in System A that has linked_system_id = System B's solar_system_id + + result = SignaturesImpl.find_forward_signature(system_a.id, 30_000_143) + assert result != nil + assert result.eve_id == "FWD-001" + assert result.type == "H296" + end + + test "returns nil when no forward signature exists", %{map: map} do + {:ok, system_a} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"}) + + result = SignaturesImpl.find_forward_signature(system_a.id, 30_000_143) + assert is_nil(result) + end + + test "returns nil when signatures exist but none link back to source", %{map: map} do + {:ok, system_a} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"}) + + # Signature in System A but not linked to anything + _unlinked_sig = + Factory.insert(:map_system_signature, %{ + system_id: system_a.id, + eve_id: "UNL-001", + type: "H296", + group: "Wormhole" + }) + + result = SignaturesImpl.find_forward_signature(system_a.id, 30_000_143) + assert is_nil(result) + end + end + + describe "back-link preference logic" do + test "forward sig time_status and mass_status take precedence over K162 defaults" do + # This tests the fixed logic: `Map.get(decoded, "time_status") || signature_time_status` + # Forward sig has time_status=1 (16h EOL), mass_status=1 (half mass) + # K162 has time_status=0 (24h), mass_status=0 (normal) + + forward_custom_info = %{"time_status" => 1, "mass_status" => 1} + k162_time_status = 0 + k162_mass_status = 0 + + # Fixed logic: always prefer forward sig values + fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status + fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status + + # Forward sig values (1) should take precedence + assert fwd_time == 1 + assert fwd_mass == 1 + end + + test "K162 values used when forward sig has nil values" do + # Forward sig has no time/mass set + forward_custom_info = %{} + k162_time_status = 2 + k162_mass_status = 2 + + fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status + fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status + + # K162 values should be used as fallback + assert fwd_time == 2 + assert fwd_mass == 2 + end + + test "0 values from forward sig are truthy in Elixir and take precedence" do + # 0 is truthy in Elixir (only nil and false are falsy) + forward_custom_info = %{"time_status" => 0, "mass_status" => 0} + k162_time_status = 2 + k162_mass_status = 2 + + fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status + fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status + + # 0 is truthy in Elixir, so forward sig's 0 values should take precedence + assert fwd_time == 0 + assert fwd_mass == 0 + end + + test "old logic (is_nil check) would incorrectly keep K162 defaults" do + # Demonstrates the bug with old logic: + # if is_nil(signature_time_status), do: Map.get(decoded, "time_status"), else: signature_time_status + # When K162 has time_status=0, is_nil(0) is false, so K162's 0 was kept + + forward_custom_info = %{"time_status" => 1, "mass_status" => 1} + k162_time_status = 0 + k162_mass_status = 0 + + # Old logic (broken): + old_fwd_time = + if is_nil(k162_time_status), + do: Map.get(forward_custom_info, "time_status"), + else: k162_time_status + + old_fwd_mass = + if is_nil(k162_mass_status), + do: Map.get(forward_custom_info, "mass_status"), + else: k162_mass_status + + # Old logic would keep K162 defaults (0) instead of forward sig values (1) + assert old_fwd_time == 0 + assert old_fwd_mass == 0 + + # New logic (fixed): + new_fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status + new_fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status + + # New logic correctly uses forward sig values + assert new_fwd_time == 1 + assert new_fwd_mass == 1 + end + end + + describe "K162 custom_info update after linking" do + test "K162 signature custom_info is updated with resolved values", %{map: map} do + {:ok, system_a} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"}) + + # K162 in system A with default values + k162_sig = + Factory.insert(:map_system_signature, %{ + system_id: system_a.id, + eve_id: "K162-001", + type: "K162", + group: "Wormhole", + custom_info: + Jason.encode!(%{"time_status" => 0, "mass_status" => 0, "k162Type" => "C2"}) + }) + + # Simulate what the linking code does: update K162's custom_info + # with resolved values from the forward signature + signature_time_status = 1 + signature_mass_status = 1 + + updated_custom_info = + (k162_sig.custom_info || "{}") + |> Jason.decode!() + |> then(fn decoded -> + decoded + |> then(fn d -> + if not is_nil(signature_time_status), + do: Map.put(d, "time_status", signature_time_status), + else: d + end) + |> then(fn d -> + if not is_nil(signature_mass_status), + do: Map.put(d, "mass_status", signature_mass_status), + else: d + end) + end) + |> Jason.encode!() + + {:ok, updated_sig} = + MapSystemSignature.update(k162_sig, %{custom_info: updated_custom_info}) + + # Verify the custom_info was updated + decoded = Jason.decode!(updated_sig.custom_info) + assert decoded["time_status"] == 1 + assert decoded["mass_status"] == 1 + # k162Type should be preserved + assert decoded["k162Type"] == "C2" + end + + test "K162 custom_info update preserves existing fields", %{map: map} do + {:ok, system_a} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"}) + + k162_sig = + Factory.insert(:map_system_signature, %{ + system_id: system_a.id, + eve_id: "K162-002", + type: "K162", + group: "Wormhole", + custom_info: + Jason.encode!(%{ + "time_status" => 0, + "mass_status" => 0, + "k162Type" => "C5", + "extra_field" => "preserved" + }) + }) + + # Only update time_status, leave mass_status nil (should not update) + signature_time_status = 2 + signature_mass_status = nil + + updated_custom_info = + (k162_sig.custom_info || "{}") + |> Jason.decode!() + |> then(fn decoded -> + decoded + |> then(fn d -> + if not is_nil(signature_time_status), + do: Map.put(d, "time_status", signature_time_status), + else: d + end) + |> then(fn d -> + if not is_nil(signature_mass_status), + do: Map.put(d, "mass_status", signature_mass_status), + else: d + end) + end) + |> Jason.encode!() + + {:ok, updated_sig} = + MapSystemSignature.update(k162_sig, %{custom_info: updated_custom_info}) + + decoded = Jason.decode!(updated_sig.custom_info) + # time_status should be updated + assert decoded["time_status"] == 2 + # mass_status should remain unchanged (nil signature_mass_status) + assert decoded["mass_status"] == 0 + # Other fields should be preserved + assert decoded["k162Type"] == "C5" + assert decoded["extra_field"] == "preserved" + end + + test "K162 custom_info update handles nil initial custom_info", %{map: map} do + {:ok, system_a} = + MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"}) + + k162_sig = + Factory.insert(:map_system_signature, %{ + system_id: system_a.id, + eve_id: "K162-003", + type: "K162", + group: "Wormhole" + # custom_info is nil by default + }) + + signature_time_status = 1 + signature_mass_status = 1 + + updated_custom_info = + (k162_sig.custom_info || "{}") + |> Jason.decode!() + |> then(fn decoded -> + decoded + |> then(fn d -> + if not is_nil(signature_time_status), + do: Map.put(d, "time_status", signature_time_status), + else: d + end) + |> then(fn d -> + if not is_nil(signature_mass_status), + do: Map.put(d, "mass_status", signature_mass_status), + else: d + end) + end) + |> Jason.encode!() + + {:ok, updated_sig} = + MapSystemSignature.update(k162_sig, %{custom_info: updated_custom_info}) + + decoded = Jason.decode!(updated_sig.custom_info) + assert decoded["time_status"] == 1 + assert decoded["mass_status"] == 1 + end + end +end