19 Commits
Author SHA1 Message Date
ntr b86a4216cf comments 2022-12-27 13:07:07 +11:00
ntr 57fefdc9e2 more notify 2022-12-27 13:01:42 +11:00
ntr 358958c127 notify wip 2022-12-27 01:05:56 +11:00
ntr c205ae81e2 format notes 2022-12-26 19:34:04 +11:00
ntr 0cec6414ae open offers fix 2022-12-26 19:02:26 +11:00
ntr 43173a833d ask for updates on route changes 2022-12-21 23:43:49 +11:00
ntr 0e785e0c8c bigint 2022-12-21 23:32:57 +11:00
ntr 6b73831b51 expand offers 2022-12-21 22:02:24 +11:00
ntr f0623cb74b toasts 2022-12-02 11:09:16 +11:00
ntr 2f8c883d75 Merge branch 'master' of ssh://git.mnml.gg:40022/home/ntr/mcrates into sample-offers 2022-12-02 09:22:25 +11:00
ntr 7262d6b37b additional typing 2022-12-02 09:18:45 +11:00
ntr dfc783c936 acp listings sync 2022-11-28 10:46:19 +11:00
ntr 631942bb41 clean up wants flow and http usage 2022-11-28 10:31:37 +11:00
ntr e74ab36313 fix offers comparison 2022-11-28 10:10:37 +11:00
ntr 5115dc5f8e create account btn 2022-11-28 09:58:34 +11:00
ntr 9e854f5d81 properly reconnect on register 2022-11-28 09:52:45 +11:00
ntr 5a19bf62fd adminconnection 2022-11-27 15:08:59 +11:00
ntr e185d04039 fix login form 2022-11-27 14:48:15 +11:00
ntr a0cd49b411 discogs and offers inferred types 2022-11-24 09:35:05 +11:00
33 changed files with 909 additions and 132 deletions

No files matched your search

+9 -9
View File
@@ -1,6 +1,14 @@
# TODO # TODO
push offers
listing ids overflowing listen / notify
on offers
notify admins
contact options on landing page
move want add to rpc
skip unofficial release skip unofficial release
@@ -8,11 +16,3 @@ status stepper
queue up wantlist adds to gracefully handle rate limiting queue up wantlist adds to gracefully handle rate limiting
push offers
cover off admin routes and commands
listen / notify
on offers
notify admins
+2 -2
View File
@@ -26,10 +26,10 @@ async function start() {
discogs, discogs,
api, api,
events, events,
notify,
} = createServices(log); } = createServices(log);
// events.emit(Events.StartupSync); await notify.connect();
// events.emit(Events.ListingsUpdated);
api.listen(); api.listen();
} }
+377 -4
View File
@@ -23,12 +23,14 @@
"koa-websocket": "^7.0.0", "koa-websocket": "^7.0.0",
"koa-ws": "^1.0.1", "koa-ws": "^1.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport-local": "^1.0.0" "passport-local": "^1.0.0",
"pg-listen": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.36", "@types/bluebird": "^3.5.36",
"@types/koa": "^2.13.5", "@types/koa": "^2.13.5",
"@types/koa-websocket": "^5.0.7",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -325,6 +327,17 @@
"@types/koa": "*" "@types/koa": "*"
} }
}, },
"node_modules/@types/koa-websocket": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/koa-websocket/-/koa-websocket-5.0.7.tgz",
"integrity": "sha512-zv5F0iaV9zk3iXLzvskUZKgX35RW7F7UNX3tK1FNQUn2nierQWlY2aM0dBTYs0kLVLRtBhsxcUdiKzDp9JPOAw==",
"dev": true,
"dependencies": {
"@types/koa": "*",
"@types/koa-compose": "*",
"@types/ws": "*"
}
},
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.14.182", "version": "4.14.182",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -382,6 +395,15 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true "dev": true
}, },
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/promise-all-settled": { "node_modules/@ungap/promise-all-settled": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
@@ -676,6 +698,15 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true "dev": true
}, },
"node_modules/buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
"peer": true,
"engines": {
"node": ">=4"
}
},
"node_modules/bufferutil": { "node_modules/bufferutil": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-1.1.0.tgz", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-1.1.0.tgz",
@@ -2871,6 +2902,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==",
"peer": true
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz",
@@ -2970,6 +3007,129 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
"integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
"peer": true,
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.5.0",
"pg-pool": "^3.5.2",
"pg-protocol": "^1.5.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-connection-string": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
"integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==",
"peer": true
},
"node_modules/pg-format": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"peer": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-listen": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pg-listen/-/pg-listen-1.7.0.tgz",
"integrity": "sha512-MKDwKLm4ryhy7iq1yw1K1MvUzBdTkaT16HZToddX9QaT8XSdt3Kins5mYH6DLECGFzFWG09VdXvWOIYogjXrsg==",
"dependencies": {
"debug": "^4.1.1",
"pg-format": "^1.0.4",
"typed-emitter": "^0.1.0"
},
"peerDependencies": {
"pg": "7.x || 8.x"
}
},
"node_modules/pg-listen/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/pg-listen/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/pg-pool": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz",
"integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==",
"peer": true,
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
"integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==",
"peer": true
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"peer": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"peer": true,
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -2982,6 +3142,45 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"peer": true,
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"peer": true,
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prisma": { "node_modules/prisma": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.5.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.5.0.tgz",
@@ -3213,6 +3412,15 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/split2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
"integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==",
"peer": true,
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -3462,6 +3670,11 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typed-emitter": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-0.1.0.tgz",
"integrity": "sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg=="
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.7.4", "version": "4.7.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
@@ -3615,7 +3828,6 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
} }
@@ -3961,6 +4173,17 @@
"@types/koa": "*" "@types/koa": "*"
} }
}, },
"@types/koa-websocket": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/koa-websocket/-/koa-websocket-5.0.7.tgz",
"integrity": "sha512-zv5F0iaV9zk3iXLzvskUZKgX35RW7F7UNX3tK1FNQUn2nierQWlY2aM0dBTYs0kLVLRtBhsxcUdiKzDp9JPOAw==",
"dev": true,
"requires": {
"@types/koa": "*",
"@types/koa-compose": "*",
"@types/ws": "*"
}
},
"@types/lodash": { "@types/lodash": {
"version": "4.14.182", "version": "4.14.182",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -4018,6 +4241,15 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true "dev": true
}, },
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
"integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@ungap/promise-all-settled": { "@ungap/promise-all-settled": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
@@ -4235,6 +4467,12 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true "dev": true
}, },
"buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
"peer": true
},
"bufferutil": { "bufferutil": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-1.1.0.tgz", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-1.1.0.tgz",
@@ -5850,6 +6088,12 @@
"p-limit": "^3.0.2" "p-limit": "^3.0.2"
} }
}, },
"packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==",
"peer": true
},
"parse5": { "parse5": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz",
@@ -5921,12 +6165,131 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
"integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
"peer": true,
"requires": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.5.0",
"pg-pool": "^3.5.2",
"pg-protocol": "^1.5.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
}
},
"pg-connection-string": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
"integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==",
"peer": true
},
"pg-format": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw=="
},
"pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"peer": true
},
"pg-listen": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pg-listen/-/pg-listen-1.7.0.tgz",
"integrity": "sha512-MKDwKLm4ryhy7iq1yw1K1MvUzBdTkaT16HZToddX9QaT8XSdt3Kins5mYH6DLECGFzFWG09VdXvWOIYogjXrsg==",
"requires": {
"debug": "^4.1.1",
"pg-format": "^1.0.4",
"typed-emitter": "^0.1.0"
},
"dependencies": {
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"pg-pool": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz",
"integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==",
"peer": true,
"requires": {}
},
"pg-protocol": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
"integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==",
"peer": true
},
"pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"peer": true,
"requires": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
}
},
"pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"peer": true,
"requires": {
"split2": "^4.1.0"
}
},
"picomatch": { "picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true "dev": true
}, },
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"peer": true
},
"postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"peer": true
},
"postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"peer": true
},
"postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"peer": true,
"requires": {
"xtend": "^4.0.0"
}
},
"prisma": { "prisma": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.5.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.5.0.tgz",
@@ -6097,6 +6460,12 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"split2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
"integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==",
"peer": true
},
"statuses": { "statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -6263,6 +6632,11 @@
"mime-types": "~2.1.24" "mime-types": "~2.1.24"
} }
}, },
"typed-emitter": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-0.1.0.tgz",
"integrity": "sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg=="
},
"typescript": { "typescript": {
"version": "4.7.4", "version": "4.7.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
@@ -6386,8 +6760,7 @@
"xtend": { "xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
"dev": true
}, },
"y18n": { "y18n": {
"version": "5.0.8", "version": "5.0.8",
+3 -1
View File
@@ -16,6 +16,7 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.36", "@types/bluebird": "^3.5.36",
"@types/koa": "^2.13.5", "@types/koa": "^2.13.5",
"@types/koa-websocket": "^5.0.7",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -40,7 +41,8 @@
"koa-websocket": "^7.0.0", "koa-websocket": "^7.0.0",
"koa-ws": "^1.0.1", "koa-ws": "^1.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport-local": "^1.0.0" "passport-local": "^1.0.0",
"pg-listen": "^1.7.0"
}, },
"overrides": { "overrides": {
"koa-passport": { "koa-passport": {
@@ -0,0 +1,81 @@
-- CreateTable
CREATE TABLE "listings" (
"id" BIGINT NOT NULL,
"seller" BIGINT NOT NULL,
"data" JSONB NOT NULL,
"wantId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "listings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wants" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"release" BIGINT NOT NULL,
"data" JSONB NOT NULL,
"userId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "offers" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"status" TEXT NOT NULL,
"condition" TEXT NOT NULL DEFAULT '',
"item" MONEY NOT NULL,
"shipping" MONEY NOT NULL,
"total" MONEY NOT NULL,
"listingId" BIGINT NOT NULL,
"userId" UUID NOT NULL,
"wantId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "offers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "listings_seller_idx" ON "listings"("seller");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "wants_release_idx" ON "wants"("release");
-- CreateIndex
CREATE UNIQUE INDEX "wants_userId_release_key" ON "wants"("userId", "release");
-- CreateIndex
CREATE UNIQUE INDEX "offers_listingId_key" ON "offers"("listingId");
-- AddForeignKey
ALTER TABLE "listings" ADD CONSTRAINT "listings_wantId_fkey" FOREIGN KEY ("wantId") REFERENCES "wants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wants" ADD CONSTRAINT "wants_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "offers" ADD CONSTRAINT "offers_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "listings"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "offers" ADD CONSTRAINT "offers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "offers" ADD CONSTRAINT "offers_wantId_fkey" FOREIGN KEY ("wantId") REFERENCES "wants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,111 @@
CREATE OR REPLACE FUNCTION notify_users() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id UUID;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('users.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_listings() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id int8;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('listings.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_offers() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id UUID;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('offers.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_wants() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id UUID;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('wants.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER notify_wants_event
AFTER INSERT OR UPDATE OR DELETE ON wants
FOR EACH ROW EXECUTE PROCEDURE notify_wants();
CREATE OR REPLACE TRIGGER notify_listings_event
AFTER INSERT OR UPDATE OR DELETE ON listings
FOR EACH ROW EXECUTE PROCEDURE notify_listings();
CREATE OR REPLACE TRIGGER notify_offers_event
AFTER INSERT OR UPDATE OR DELETE ON offers
FOR EACH ROW EXECUTE PROCEDURE notify_offers();
CREATE OR REPLACE TRIGGER notify_users_event
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE PROCEDURE notify_users();
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+7 -4
View File
@@ -11,8 +11,8 @@ datasource db {
} }
model Listing { model Listing {
id Int @id id BigInt @id
seller Int seller BigInt
data Json data Json
wantId String @db.Uuid wantId String @db.Uuid
@@ -43,7 +43,7 @@ model User {
model Want { model Want {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
release Int release BigInt
data Json data Json
userId String @db.Uuid userId String @db.Uuid
@@ -71,7 +71,7 @@ model Offer {
shipping Decimal @db.Money shipping Decimal @db.Money
total Decimal @db.Money total Decimal @db.Money
listingId Int listingId BigInt
listing Listing @relation(fields: [listingId], references: [id]) listing Listing @relation(fields: [listingId], references: [id])
userId String @db.Uuid userId String @db.Uuid
@@ -80,6 +80,9 @@ model Offer {
wantId String @db.Uuid wantId String @db.Uuid
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade) want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
// only one offer per want for a user // only one offer per want for a user
// @@unique([userId, wantId]) // @@unique([userId, wantId])
@@unique([listingId]) @@unique([listingId])
+14 -19
View File
@@ -13,12 +13,16 @@ import Bluebird from 'bluebird';
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { Listing, User, Want, Prisma } from '@prisma/client'; import type { Listing, User, Want, Prisma } from '@prisma/client';
import { Pg } from './pg'; import type { Pg } from './pg';
import type { Discogs } from './discogs';
import type { Offers } from './offers';
import type { Rpc } from './rpc';
import routerAuth from './routers/router.auth'; import routerAuth from './routers/router.auth';
import { Events } from './events'; import { Events } from './events';
import createRpc, { RpcMessageType } from './rpc'; import createRpc, { RpcMessageType } from './rpc';
export default function createApi(log: Log, pg: Pg, discogs, offers, events) { export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Offers, events) {
const PORT = 41337; const PORT = 41337;
const WS_PORT = 40055; const WS_PORT = 40055;
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth'; const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
@@ -59,13 +63,6 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
ctx.status = 200; ctx.status = 200;
}); });
router.get('/user/offers', async function userOffersGet(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user;
const offers = await pg.userOffers(user);
ctx.response.body = offers;
});
router.post('/want', async function wantPost(ctx, next) { router.post('/want', async function wantPost(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required'); ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { releaseId } = ctx.request.body; const { releaseId } = ctx.request.body;
@@ -85,14 +82,6 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
ctx.response.body = wants; ctx.response.body = wants;
}); });
router.get('/want/all', async function wantGet(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user;
const wants = await pg.wantList(user);
ctx.response.body = wants;
});
router.post('/want/search', async function wantSearch(ctx, next) { router.post('/want/search', async function wantSearch(ctx, next) {
const { query } = ctx.request.body; const { query } = ctx.request.body;
const results = await discogs.search(query); const results = await discogs.search(query);
@@ -110,15 +99,16 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
app.ws.use(passport.initialize()); app.ws.use(passport.initialize());
app.ws.use(passport.session()); app.ws.use(passport.session());
app.ws.use(async (ctx, next) => { app.ws.use(async (ctx, next) => {
try {
log.info('new ws connection', { user: ctx.state.user }); log.info('new ws connection', { user: ctx.state.user });
// log.child... // log.child...
const ws = ctx.websocket; const ws = ctx.websocket;
const rpc = createRpc(log, pg, ctx.state.user, ws, discogs, offers); const rpc = createRpc(log, pg, ws, discogs, offers, events, ctx.state.user as User | null);
if (ctx.state.user) events.emit(Events.Connection, { ws, id: ctx.state.user.id });
ws.on('message', async function incoming(message) { ws.on('message', async function incoming(message) {
try { try {
const parsed = JSON.parse(message.toString()); const parsed = JSON.parse(message.toString());
@@ -136,7 +126,12 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
log.info('ws disconnected', { user: ctx.state.user }); log.info('ws disconnected', { user: ctx.state.user });
}); });
if (ctx.state.user) events.emit(Events.Connection, { rpc, id: ctx.state.user.id });
await rpc.onConnection(); await rpc.onConnection();
} catch (e) {
log.error(e);
}
}); });
return { return {
+3 -1
View File
@@ -48,7 +48,7 @@ export default function createDiscogs(log: Log, pg: Pg) {
// @ts-ignore // @ts-ignore
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value); const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
log.info(additions); log.info('want list additions', additions);
return additions; return additions;
} }
@@ -112,3 +112,5 @@ export default function createDiscogs(log: Log, pg: Pg) {
search, search,
}; };
} }
export type Discogs = ReturnType<typeof createDiscogs>;
+19 -7
View File
@@ -2,22 +2,25 @@ import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'timers/promises';
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { Want } from '@prisma/client'; import type { Want, Offer } from '@prisma/client';
import { Pg } from './pg'; import { Pg } from './pg';
import { Rpc } from './rpc';
import { wantDetails } from './utils'; import { wantDetails } from './utils';
import routerAuth from './routers/router.auth'; import routerAuth from './routers/router.auth';
export enum Events { export enum Events {
WantsAdded = 'WantsAdded', WantsAdded = 'WantsAdded',
StartupSync = 'StartupSync', Sync = 'Sync',
ListingsUpdated = 'ListingsUpdated', ListingsUpdated = 'ListingsUpdated',
Connection = 'Connection', Connection = 'Connection',
OffersUpdated = 'OffersUpdated'
} }
export default function createEvents(log: Log, pg: Pg, discogs, offers) { export default function createEvents(log: Log, pg: Pg, discogs, offers) {
const events = new EventEmitter({ captureRejections: true }); const events = new EventEmitter({ captureRejections: true });
const connections = new Map(); const connections = new Map<UUID, Rpc>();
async function populateListings(want: Want) { async function populateListings(want: Want) {
try { try {
@@ -51,11 +54,12 @@ export default function createEvents(log: Log, pg: Pg, discogs, offers) {
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population'); log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population');
await updateWantListings(wants); await updateWantListings(wants);
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings'); log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings');
events.emit(Events.ListingsUpdated);
}); });
events.on(Events.StartupSync, async function startupSync() { events.on(Events.Sync, async function sync() {
const wants = await pg.wantsToSync(); const wants = await pg.wantsToSync();
log.info({ event: Events.StartupSync, wants: wants.length }); log.info({ event: Events.Sync, wants: wants.length });
await updateWantListings(wants); await updateWantListings(wants);
events.emit(Events.ListingsUpdated); events.emit(Events.ListingsUpdated);
}); });
@@ -65,9 +69,17 @@ export default function createEvents(log: Log, pg: Pg, discogs, offers) {
log.debug({ offers: updatedOffers.length }, 'offers up to date'); log.debug({ offers: updatedOffers.length }, 'offers up to date');
}); });
events.on(Events.Connection, async function onConnection({ ws, id }) { events.on(Events.Connection, async function onConnection({ rpc, id }) {
connections.set(id, ws); log.debug('new rpc connection', { id, rpc });
connections.set(id, rpc);
}) })
events.on(Events.OffersUpdated, async function onOffersUpdated(offer: Offer) {
const rpc = connections.get(offer.userId);
if (!rpc) return null;
log.debug({ event: Events.OffersUpdated, rpc, offer });
return rpc.sendUserOffers();
});
return events; return events;
} }
+20
View File
@@ -0,0 +1,20 @@
import createSubscriber from 'pg-listen'
import type Log from 'bunyan';
import { Events } from './events';
export default function createNotify(log: Log, events) {
const CHANNEL = 'events';
const subscriber = createSubscriber({ connectionString: process.env.DATABASE_URL });
subscriber.notifications.on(CHANNEL, ({ table, action, record }) => {
if (table === 'offers') return events.emit(Events.OffersUpdated, record);
})
async function connect() {
await subscriber.connect();
return subscriber.listenTo(CHANNEL);
}
return { connect };
}
+55
View File
@@ -0,0 +1,55 @@
import createSubscriber from 'pg-listen'
import type Log from 'bunyan';
import { Events } from './events';
import { Pg } from './pg';
export default function createNotify(log: Log, pg: Pg, events) {
const OFFERS_CHANNEL = 'offers.events';
const USERS_CHANNEL = 'users.events';
const LISTINGS_CHANNEL = 'listings.events';
const WANTS_CHANNEL = 'wants.events';
const subscriber = createSubscriber({ connectionString: process.env.DATABASE_URL });
// pg has a hard limit of 8000B for the notification payload
// so we only receive the ids of the updated record
// and go fetch the whole thing to figure out who to notify
async function handleOffers(payload) {
const { action, id } = payload;
log.info({ channel: OFFERS_CHANNEL, action, id });
}
async function handleUsers(payload) {
const { action, id } = payload;
log.info({ channel: USERS_CHANNEL, action, id });
}
async function handleListings(payload) {
const { action, id } = payload;
log.info({ channel: LISTINGS_CHANNEL, action, id });
}
async function handleWants(payload) {
const { action, id } = payload;
log.info({ channel: WANTS_CHANNEL, action, id });
}
subscriber.notifications.on(OFFERS_CHANNEL, handleOffers);
subscriber.notifications.on(USERS_CHANNEL, handleUsers);
subscriber.notifications.on(LISTINGS_CHANNEL, handleListings);
subscriber.notifications.on(WANTS_CHANNEL, handleWants);
async function connect() {
await subscriber.connect();
await Promise.all([
subscriber.listenTo(OFFERS_CHANNEL),
subscriber.listenTo(USERS_CHANNEL),
subscriber.listenTo(LISTINGS_CHANNEL),
subscriber.listenTo(WANTS_CHANNEL),
]);
}
return { connect };
}
+15 -4
View File
@@ -51,8 +51,7 @@ export default function createOffers(log: Log, pg: Pg) {
// TODO probably need to take one of each condition of record from a store // TODO probably need to take one of each condition of record from a store
// eg. they have a VG+ and a M we should make offers for each // eg. they have a VG+ and a M we should make offers for each
log.info(sorted); log.trace('generated offers', sorted);
const inserts = await pg.offersUpdate(sorted); const inserts = await pg.offersUpdate(sorted);
return inserts; return inserts;
@@ -113,12 +112,14 @@ export default function createOffers(log: Log, pg: Pg) {
const offers = await pg.userOffers(user); const offers = await pg.userOffers(user);
const groups = Object.values(groupBy(offers, (o) => o.wantId)) const groups = Object.values(groupBy(offers, (o) => o.wantId))
.map(g => { .map(g => {
const offers = sortBy(g, sortOffers);
// the offer status is just the most advanced status // the offer status is just the most advanced status
// based on sort // based on sort
const offers = sortBy(g, sortOffers);
const status = offers[0].status; const status = offers[0].status;
// now actually sort them by price
offers.sort((a, b) => a.total - b.total);
const details = offers[0].want.data; const details = offers[0].want.data;
return { return {
@@ -131,11 +132,21 @@ export default function createOffers(log: Log, pg: Pg) {
return groups; return groups;
} }
async function samples() {
const user = await pg.userFindEmail('ntr@mnml.gg');
const offerGroups = await userOffers(user);
const i = Math.floor(Math.random() * offerGroups.length / 2);
return offerGroups[i];
}
return { return {
generate, generate,
accept, accept,
statusSet, statusSet,
acpOffers, acpOffers,
userOffers, userOffers,
samples,
}; };
} }
export type Offers = ReturnType<typeof createOffers>;
+22 -13
View File
@@ -21,7 +21,10 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
async function userWants(user: User) { async function userWants(user: User) {
return prisma.want.findMany({ where: { userId: user.id }}); return prisma.want.findMany({
where: { userId: user.id },
include: { _count: { select: { offers: true }} },
});
} }
async function userOffers(user: User) { async function userOffers(user: User) {
@@ -34,6 +37,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
want: true, want: true,
}, },
orderBy: [ orderBy: [
{ updatedAt: 'asc' },
{ total: 'asc' }, { total: 'asc' },
{ listingId: 'asc' }, { listingId: 'asc' },
], ],
@@ -127,8 +131,8 @@ export default function createPg(log: Log, prisma: PrismaClient) {
const protectedOfferStatuses = [Status.Confirmed, Status.Complete]; const protectedOfferStatuses = [Status.Confirmed, Status.Complete];
// can't delete a listing with an open offer // delete all the offers where we don't want to keep them
// so delete those offers first // for historical reasons
const { count: offersRemoved } = await prisma.offer.deleteMany({ const { count: offersRemoved } = await prisma.offer.deleteMany({
where: { where: {
wantId: want.id, wantId: want.id,
@@ -141,7 +145,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
}); });
if (offersRemoved > 0) log.info({ offersRemoved }, 'open offers removed'); if (offersRemoved > 0) log.info({ offersRemoved }, 'pending offers removed');
// now we have to filter listingIds with confirmed offers // now we have to filter listingIds with confirmed offers
// and get their listingIds to filter out // and get their listingIds to filter out
@@ -150,25 +154,26 @@ export default function createPg(log: Log, prisma: PrismaClient) {
wantId: want.id, wantId: want.id,
status: { status: {
in: protectedOfferStatuses, in: protectedOfferStatuses,
} },
}, },
select: { select: {
listingId: true, listingId: true,
} },
})).map(r => r.listingId); })).map(r => r.listingId);
const removalIds = _.without(currentListingIds, ...confirmedListingIds); const removalIds = currentListingIds.filter(id => confirmedListingIds.includes(id));
if (removalIds.length) log.info({ removalIds });
// finally we delete listings that aren't related // finally we delete listings that aren't related
// to a historical offer // to a historical offer
const { count } = await prisma.listing.deleteMany({ const { count } = await prisma.listing.deleteMany({
where: { where: {
wantId: want.id, id: { in: removalIds },
id: { },
notIn: currentListingIds,
}
}
}); });
log.info({ count }, 'second stage open offers removed');
return count; return count;
}); });
} }
@@ -221,6 +226,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
// but for ease of abstraction we get the listings themselves // but for ease of abstraction we get the listings themselves
// grouped by seller // grouped by seller
const candidates = await prisma.$queryRaw` const candidates = await prisma.$queryRaw`
WITH groups AS (
select select
seller, count(distinct("wantId")), array_agg(id) as listings seller, count(distinct("wantId")), array_agg(id) as listings
from from
@@ -231,7 +237,10 @@ export default function createPg(log: Log, prisma: PrismaClient) {
seller seller
order by order by
count desc count desc
limit 5; )
select *
from groups
where count >= 5;
` as BundleCandidate[]; ` as BundleCandidate[];
return Promise.all(candidates.map(async c => prisma.listing.findMany({ return Promise.all(candidates.map(async c => prisma.listing.findMany({
+45 -4
View File
@@ -1,14 +1,16 @@
import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises';
import assert from 'assert/strict'; import assert from 'assert/strict';
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { Want, Offer, User } from '@prisma/client'; import type { Want, Offer, User } from '@prisma/client';
import { Pg } from './pg'; import type { Pg } from './pg';
import type { Discogs } from './discogs';
import type { Offers } from './offers';
import { Status } from './offers'; import { Status } from './offers';
import { isAdmin } from './utils'; import { isAdmin } from './utils';
import { Events } from './events';
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType { function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
if (RpcMessageType[msgType]) { if (RpcMessageType[msgType]) {
@@ -18,7 +20,7 @@ function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
} }
// rpc is created per connection from the api // rpc is created per connection from the api
export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, offers) { export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, events, user: User | null) {
async function ping(): Promise<PongMessage> { async function ping(): Promise<PongMessage> {
return [RpcMessageType.Pong, []]; return [RpcMessageType.Pong, []];
} }
@@ -68,6 +70,12 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
return [RpcMessageType.AcpOffers, await offers.acpOffers()]; return [RpcMessageType.AcpOffers, await offers.acpOffers()];
} }
async function acpListingsSync(): Promise<AcpListingsSyncMessage> {
assert(isAdmin(user), 'not authorised');
events.emit(Events.Sync);
return [RpcMessageType.AcpListingsSync, []];
}
async function receive(message: any): Promise<RpcMessage> { async function receive(message: any): Promise<RpcMessage> {
const [msgType, params] = message; const [msgType, params] = message;
@@ -98,6 +106,8 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
return acpOfferStatusSet(params); return acpOfferStatusSet(params);
case RpcMessageType.AcpOffers: case RpcMessageType.AcpOffers:
return acpOffers(); return acpOffers();
case RpcMessageType.AcpListingsSync:
return acpListingsSync();
default: default:
throw new Error(`Message type ${msgType} is known but unhandled`); throw new Error(`Message type ${msgType} is known but unhandled`);
@@ -109,13 +119,28 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
throw new Error(`Invalid parameters for message type ${msgType}: ${JSON.stringify(message)}`); throw new Error(`Invalid parameters for message type ${msgType}: ${JSON.stringify(message)}`);
} }
async function anonConnection() {
}
async function adminConnection() {
// don't need it, requested on route
// send(await acpOffers());
}
async function onConnection() { async function onConnection() {
if (!user) return anonConnection();
const userMsg: UserMessage = [RpcMessageType.User, user]; const userMsg: UserMessage = [RpcMessageType.User, user];
send(userMsg); send(userMsg);
await sendUserOffers();
const wants = await pg.userWants(user); const wants = await pg.userWants(user);
const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants]; const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
send(wantsMsg); send(wantsMsg);
if (isAdmin(user)) return adminConnection();
} }
function send(msg: RpcMessage) { function send(msg: RpcMessage) {
@@ -126,13 +151,23 @@ export default function createRpc(log: Log, pg: Pg, user: User, ws, discogs, off
)) ))
} }
async function sendUserOffers() {
const userOffers = await offers.userOffers(user);
const offersMsg: OffersMessage = [RpcMessageType.Offers, userOffers];
send(offersMsg);
}
return { return {
receive, receive,
send, send,
onConnection, onConnection,
sendUserOffers,
} }
} }
export type Rpc = ReturnType<typeof createRpc>;
export enum RpcMessageType { export enum RpcMessageType {
Ping = 'Ping', Ping = 'Ping',
Pong = 'Pong', Pong = 'Pong',
@@ -148,10 +183,12 @@ export enum RpcMessageType {
Error = 'Error', Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet', OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet', AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers', AcpOffers = 'AcpOffers',
AcpListingsSync = 'AcpListingsSync',
WantRm = 'WantRm', WantRm = 'WantRm',
} }
@@ -164,9 +201,11 @@ type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]]; type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]]; type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]]; type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]]; type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]]; type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
export type RpcMessage = export type RpcMessage =
PingMessage | PingMessage |
@@ -178,10 +217,12 @@ export type RpcMessage =
WantsMessage | WantsMessage |
OffersMessage | OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage | OfferStatusSetMessage |
AcpOfferStatusSetMessage | AcpOfferStatusSetMessage |
AcpOffersMessage | AcpOffersMessage |
AcpListingsSyncMessage |
ErrorMessage; ErrorMessage;
+4 -1
View File
@@ -6,19 +6,22 @@ import createLog from './log';
import createApi from './api'; import createApi from './api';
import createEvents from './events'; import createEvents from './events';
import createPg from './pg'; import createPg from './pg';
import createNotify from './notify';
export default function creates(log) { export default function createServices(log) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const pg = createPg(log, prisma); const pg = createPg(log, prisma);
const discogs = createDiscogs(log, pg); const discogs = createDiscogs(log, pg);
const offers = createOffers(log, pg); const offers = createOffers(log, pg);
const events = createEvents(log, pg, discogs, offers); const events = createEvents(log, pg, discogs, offers);
const notify = createNotify(log, pg, events);
const api = createApi(log, pg, discogs, offers, events); const api = createApi(log, pg, discogs, offers, events);
return { return {
api, api,
discogs, discogs,
events, events,
notify,
pg, pg,
prisma, prisma,
}; };
+11
View File
@@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.5", "@reduxjs/toolkit": "^1.8.5",
"izitoast": "^1.4.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"logrocket": "^3.0.1", "logrocket": "^3.0.1",
"preact": "^10.10.0", "preact": "^10.10.0",
@@ -10844,6 +10845,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/izitoast": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
},
"node_modules/jake": { "node_modules/jake": {
"version": "10.8.5", "version": "10.8.5",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
@@ -29566,6 +29572,11 @@
"istanbul-lib-report": "^3.0.0" "istanbul-lib-report": "^3.0.0"
} }
}, },
"izitoast": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
},
"jake": { "jake": {
"version": "10.8.5", "version": "10.8.5",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.5", "@reduxjs/toolkit": "^1.8.5",
"izitoast": "^1.4.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"logrocket": "^3.0.1", "logrocket": "^3.0.1",
"preact": "^10.10.0", "preact": "^10.10.0",
+2
View File
@@ -33,6 +33,8 @@ const Main = () => {
const { ws } = store.getState(); const { ws } = store.getState();
if (!ws) return null; if (!ws) return null;
if (e.url === '/acp') return ws.sendAcpOffers(); if (e.url === '/acp') return ws.sendAcpOffers();
if (e.url === '/') return ws.sendOffers();
if (e.url === '/list') return ws.sendWants();
} }
return ( return (
+5 -1
View File
@@ -58,12 +58,16 @@ function OfferPriceBtn(offer) {
export default function OfferGroup(group, i) { export default function OfferGroup(group, i) {
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
const format = group.details.formats[0].text ? `${group.details.formats[0].text} - ` : '';
const descriptions = group.details.formats.map(f => f.descriptions.join(' ')).join(' ')
const detailText = `${format} ${descriptions}`;
return ( return (
<div class="offer" key={i}> <div class="offer" key={i}>
<section> <section>
<h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank"><ExternalLink /></a></h3> <h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank"><ExternalLink /></a></h3>
<div class='alt-text details'> <div class='alt-text details'>
{group.details.year} - {group.details.formats.map(f => f.descriptions.join(' ')).join(' ')} {group.details.year} - {detailText}
</div> </div>
<div class='alt-text details'>Status: {group.status}</div> <div class='alt-text details'>Status: {group.status}</div>
</section> </section>
+2 -2
View File
@@ -30,8 +30,8 @@ function Want(want) {
</div> </div>
<img src={r.thumb} /> <img src={r.thumb} />
<div class='details'> <div class='details'>
<div class='alt'>{r.year}</div> <div class='alt'>{r.year} - {r.formats[0].descriptions.join(' ')}</div>
<div class='alt'>{r.formats[0].descriptions.join(' ')}</div> <div class='alt'>{want._count.offers} offers</div>
</div> </div>
<button onClick={onClick} >{text}</button> <button onClick={onClick} >{text}</button>
</article> </article>
+4 -15
View File
@@ -32,21 +32,7 @@ function onError(error) {
api.interceptors.response.use(onSuccess, onError); api.interceptors.response.use(onSuccess, onError);
const actions = { const actions = {
getBundles: async function getBundles() { // TODO move to rpc
return api.get('/acp/bundles');
},
getOffers: async function getOffers() {
return api.get('/user/offers');
},
getOffersAcp: async function getOffersAcp() {
return api.get('/acp/offers');
},
getWants: async function getWants() {
return api.get('/want/all');
},
postWantSearch: async function postWantSearch(query) { postWantSearch: async function postWantSearch(query) {
return api.post('/want/search', { query }); return api.post('/want/search', { query });
@@ -60,6 +46,9 @@ const actions = {
return api.post('/want/discogs', { username }); return api.post('/want/discogs', { username });
}, },
// --------------------------------
// require as http to set cookies
getLogin: async function getLogin() { getLogin: async function getLogin() {
return api.get('/auth/login') return api.get('/auth/login')
}, },
+2
View File
@@ -1,4 +1,6 @@
import 'izitoast/dist/css/iziToast.min.css';
import './style/index.less'; import './style/index.less';
import App from './app'; import App from './app';
export default App; export default App;
+7 -10
View File
@@ -70,22 +70,19 @@ function Home() {
const offers = useSelector((state: State) => state.acpOffers); const offers = useSelector((state: State) => state.acpOffers);
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
// useEffect(() => { function sync() {
// ws.sendAcpOffers(); ws.sendAcpListingsSync();
// }, [offers, ws]);
if (!offers.length) {
return (
<div class={'offers'}>
loading...
</div>
);
} }
return ( return (
<div>
<button onClick={sync}> sync listings </button>
<button onClick={sync}> generate offers </button>
<hr />
<div class='bundles'> <div class='bundles'>
{offers.map(SellerGroup)} {offers.map(SellerGroup)}
</div> </div>
</div>
); );
} }
+4 -7
View File
@@ -4,23 +4,20 @@ import { useSelector } from 'react-redux'
import { route } from 'preact-router'; import { route } from 'preact-router';
import { State } from './../store' import { State } from './../store'
import * as toast from './../toast';
import Want from './../components/want'; import Want from './../components/want';
interface Props { const List = () => {
user: any,
wants: any[],
getUserOffers: (userId: string) => void,
}
const List = ({ user }: Props) => {
const wants = useSelector((state: State) => state.wants); const wants = useSelector((state: State) => state.wants);
const user = useSelector((state: State) => state.user);
if (!wants) { if (!wants) {
return <div> loading... </div>; return <div> loading... </div>;
} }
if (!wants.length) { if (!wants.length) {
toast.info('List Empty', 'You have no items in your list, add some with search.');
route('/search'); route('/search');
} }
+25 -17
View File
@@ -12,12 +12,13 @@ const Login = ({ mode }) => {
const user = useSelector((state: State) => state.user); const user = useSelector((state: State) => state.user);
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
const [email, setEmail] = useState('')
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
async function postLogin() { function gotoRegister() {
return route('/register');
}
async function postLogin(email, password) {
const [error, user] = await http.postLogin(email, password); const [error, user] = await http.postLogin(email, password);
if (error) return setError(error); if (error) return setError(error);
route('/'); route('/');
@@ -26,17 +27,23 @@ const Login = ({ mode }) => {
ws.connect(); ws.connect();
} }
async function postRegister() { async function postRegister(email, password, confirm) {
if (password !== confirm) return setError('passwords do not match');
const [error, user] = await http.postRegister(email, password); const [error, user] = await http.postRegister(email, password);
if (error) return setError(error); if (error) return setError(error);
route('/search'); route('/search');
dispatch(userState.actions.set(user)); dispatch(userState.actions.set(user));
// reconnect ws to get logged in methods
ws.connect();
} }
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
if (mode === 'login') return postLogin()
if (mode === 'register') return postRegister(); console.log(e.target.email, e.target.password, e.target.confirm);
if (mode === 'login') return postLogin(e.target.email.value, e.target.password.value);
if (mode === 'register') return postRegister(e.target.email.value, e.target.password.value, e.target.confirm.value);
} }
const emailRef = useRef<HTMLInputElement>() const emailRef = useRef<HTMLInputElement>()
@@ -52,21 +59,21 @@ const Login = ({ mode }) => {
if (mode === 'register') return ( if (mode === 'register') return (
<div class="login"> <div class="login">
{errorEl} {errorEl}
<form action=""> <form action="" onSubmit={handleSubmit}>
<div class="item"> <div class="item">
<label for="email">email</label> <label for="email">email</label>
<input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} /> <input type="email" name="email" id="email" ref={emailRef} required />
</div> </div>
<div class="item"> <div class="item">
<label for="password">password</label> <label for="password">password</label>
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} /> <input type="password" name="password" id="password" required />
</div> </div>
<div class="item"> <div class="item">
<label for="password">confirm password</label> <label for="password">confirm password</label>
<input type="password" name="password" id="password" value={confirmPassword} required onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)} /> <input type="password" name="confirm" id="confirm" required />
</div> </div>
<div class="item"> <div class="item">
<input type="submit" value="register" onClick={handleSubmit} /> <input type="submit" value="register" />
</div> </div>
</form> </form>
</div> </div>
@@ -76,19 +83,20 @@ const Login = ({ mode }) => {
return ( return (
<div class="login"> <div class="login">
{errorEl} {errorEl}
<form action=""> <form action="" onSubmit={handleSubmit}>
<div class="item"> <div class="item">
<label for="email">email</label> <label for="email">email</label>
<input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} /> <input type="email" name="email" id="email" ref={emailRef} />
</div> </div>
<div class="item"> <div class="item">
<label for="password">password</label> <label for="password">password</label>
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} /> <input type="password" name="password" id="password" />
</div> </div>
<div class="item"> <div class="item">
<input type="submit" value="login" onClick={handleSubmit} /> <input type="submit" value="login"/>
</div> </div>
</form> </form>
<button onClick={gotoRegister}> create account </button>
</div> </div>
); );
}; };
@@ -101,7 +109,7 @@ function LoginPage({ mode }) {
<div class='words'> <div class='words'>
<p>Mates Crates is a record importing service.</p> <p>Mates Crates is a record importing service.</p>
<p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p> <p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p>
<p>Based in Preston.</p> <p>Records can then be picked up from Preston.</p>
</div> </div>
</div> </div>
) )
+4 -5
View File
@@ -1,18 +1,17 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router'; import { route } from 'preact-router';
import { useSelector, useDispatch } from 'react-redux' import { useSelector } from 'react-redux'
import http from './../http'; import { State } from './../store';
import { State, offersState } from './../store'; import * as toast from './../toast';
import OfferGroup from './../components/offer.group'; import OfferGroup from './../components/offer.group';
function Home() { function Home() {
const dispatch = useDispatch()
const offers = useSelector((state: State) => state.offers); const offers = useSelector((state: State) => state.offers);
if (offers && offers.length === 0) { if (offers && offers.length === 0) {
toast.info('no offers', 'You have no offers yet, search for some more items.');
route('/search'); route('/search');
} }
+10 -3
View File
@@ -1,10 +1,13 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks'; import { useState, useRef, useEffect } from 'preact/hooks';
import { ExternalLink, Check } from 'react-feather'; import { ExternalLink, Check } from 'react-feather';
import { useSelector } from 'react-redux'
import { State } from './../store';
import http from '../http'; import http from '../http';
function Result(r) { function Result(r, wants) {
const [added, setAdded] = useState(false) const [added, setAdded] = useState(false)
async function onClick() { async function onClick() {
@@ -13,7 +16,9 @@ function Result(r) {
setAdded(true); setAdded(true);
} }
const classes = `${added ? 'added' : ''}`; const wanted = wants.some(w => w.release === r.id);
const classes = `${added || wanted ? 'added' : ''}`;
const btnText = added const btnText = added
? <Check /> ? <Check />
: '+'; : '+';
@@ -39,6 +44,8 @@ const User = () => {
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [infoText, setInfoText] = useState(''); const [infoText, setInfoText] = useState('');
const wants = useSelector((state: State) => state.wants);
async function fetchResults() { async function fetchResults() {
const [error, results] = await http.postWantSearch(query); const [error, results] = await http.postWantSearch(query);
@@ -106,7 +113,7 @@ const User = () => {
</div> </div>
<div class='results'> <div class='results'>
{infoEl} {infoEl}
{results.map(Result)} {results.map(r => Result(r, wants))}
{topEl} {topEl}
</div> </div>
</div> </div>
+1
View File
@@ -52,6 +52,7 @@ const reducer = combineReducers({
const store = createStore( const store = createStore(
reducer, reducer,
// @ts-ignore -- devtools window exists on dev
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
); );
+18
View File
@@ -541,3 +541,21 @@ header {
} }
} }
// ------------
// toasts
.iziToast {
border-radius: 0;
border-color: var(--base-border-color);
.iziToast-body {
font-family: var(--base-font-family);
font-size: var(--base-font-size);
}
.iziToast-title {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}
}
+15
View File
@@ -0,0 +1,15 @@
import toast from 'izitoast';
export function info(title, message) {
toast.info({
title,
message,
position: 'bottomCenter',
color: '#1f2128',
titleColor: '#F4F1D0',
messageColor: '#F4F1D0',
iconColor: '#F4F1D0',
icon: '',
theme: 'dark',
});
}
+8 -3
View File
@@ -1,5 +1,7 @@
import { wantsState, wsState, userState, offersState, acpOffersState } from './store' import { wantsState, wsState, userState, offersState, acpOffersState } from './store'
import * as toast from './toast';
const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`; const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`;
export default function createSocket(dispatch) { export default function createSocket(dispatch) {
@@ -50,6 +52,10 @@ export default function createSocket(dispatch) {
send(['AcpOfferStatusSet', { id, status }]); send(['AcpOfferStatusSet', { id, status }]);
} }
function sendAcpListingsSync() {
send(['AcpListingsSync', {}]);
}
let pongTimeout; let pongTimeout;
function onPong() { function onPong() {
pongTimeout = setTimeout(sendPing, 5000); pongTimeout = setTimeout(sendPing, 5000);
@@ -111,9 +117,6 @@ export default function createSocket(dispatch) {
function onOpen() { function onOpen() {
console.log('websocket connected', SOCKET_URL) console.log('websocket connected', SOCKET_URL)
sendPing(); sendPing();
sendAcpOffers();
sendOffers();
sendWants();
} }
function onError(event) { function onError(event) {
@@ -154,8 +157,10 @@ export default function createSocket(dispatch) {
sendWantRm, sendWantRm,
sendOffers, sendOffers,
sendWants,
sendAcpOffers, sendAcpOffers,
sendAcpOfferStatusSet, sendAcpOfferStatusSet,
sendAcpListingsSync,
}; };
} }