Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f853696ab | ||
|
|
bbc3fe6b48 | ||
|
|
f2294def05 | ||
|
|
65d37eeb61 | ||
|
|
0bf75d7faa | ||
|
|
cf01670ee6 | ||
|
|
a43d4f32e4 | ||
|
|
7ea2c37116 | ||
|
|
4041d9827d | ||
|
|
94dfe7c082 | ||
|
|
20a587600e | ||
|
|
106921a0da | ||
|
|
2804477601 | ||
|
|
44c83d3b33 | ||
|
|
70cb07ee27 | ||
|
|
9d21ee63d3 | ||
|
|
82c905650f | ||
|
|
047f606e83 | ||
|
|
deb9eb3fc2 | ||
|
|
453c2da045 | ||
|
|
323c7877b1 | ||
|
|
30bf7426e4 | ||
|
|
f5e75f4fe6 | ||
|
|
c99f6fd2b6 | ||
|
|
12f00eb639 | ||
|
|
7121b2f4cd | ||
|
|
b0bca74ee2 | ||
|
|
9a42c290ae | ||
|
|
a8e5e5d7cd | ||
|
|
65e603f222 | ||
|
|
413aeedadf | ||
|
|
faa2e70e08 | ||
|
|
bd32044427 | ||
|
|
b4114a2440 | ||
|
|
66dcdaeb70 | ||
|
|
b622d2cdbd |
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
push offers
|
||||||
|
redux that bitch
|
||||||
|
|
||||||
|
|
||||||
|
seller
|
||||||
|
eg min 6 items
|
||||||
|
shipping is 101aud
|
||||||
File renamed without changes.
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extension": ["ts"],
|
||||||
|
"spec": "src/**/*.test.ts",
|
||||||
|
"require": "ts-node/register",
|
||||||
|
|
||||||
|
"watch-files": "**/*.ts",
|
||||||
|
"watch-ignore": ["db"]
|
||||||
|
}
|
||||||
File renamed without changes.
Vendored
+2
@@ -1,9 +1,11 @@
|
|||||||
// global type aliases and whatnot
|
// global type aliases and whatnot
|
||||||
|
|
||||||
type UUID = string;
|
type UUID = string;
|
||||||
|
type Email = string;
|
||||||
|
|
||||||
type DiscogsName = string;
|
type DiscogsName = string;
|
||||||
type DiscogsListing = number;
|
type DiscogsListing = number;
|
||||||
|
type DiscogsRelease = number;
|
||||||
type DiscogsSeller = number;
|
type DiscogsSeller = number;
|
||||||
|
|
||||||
type BundleCandidate = {
|
type BundleCandidate = {
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import createServices from './src/services';
|
||||||
|
import createLog from './src/log';
|
||||||
|
import { Events } from './src/events';
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const log = createLog();
|
||||||
|
|
||||||
|
// process.on('uncaughtException', (err, origin) => {
|
||||||
|
// log.error({ err, origin }, 'uncaughtException');
|
||||||
|
// process.exit(1);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
// log.error({ reason, promise }, 'unhandledRejection');
|
||||||
|
// process.exit(1);
|
||||||
|
// });
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
log.debug('SIGTERM received, exiting');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
prisma,
|
||||||
|
pg,
|
||||||
|
discogs,
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
} = createServices(log);
|
||||||
|
|
||||||
|
events.emit(Events.StartupSync);
|
||||||
|
// events.emit(Events.ListingsUpdated);
|
||||||
|
|
||||||
|
api.listen();
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
Generated
+6465
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "mocha",
|
||||||
|
"test:watch": "mocha --watch",
|
||||||
"migrate": "knex migrate:latest",
|
"migrate": "knex migrate:latest",
|
||||||
"migrate:make": "prisma migrate dev --name",
|
"migrate:make": "prisma migrate dev --name",
|
||||||
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
|
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
|
||||||
@@ -12,20 +13,38 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bluebird": "^3.5.36",
|
"@types/bluebird": "^3.5.36",
|
||||||
|
"@types/koa": "^2.13.5",
|
||||||
"@types/lodash": "^4.14.182",
|
"@types/lodash": "^4.14.182",
|
||||||
|
"@types/mocha": "^9.1.1",
|
||||||
|
"axios": "^0.27.2",
|
||||||
|
"mocha": "^10.0.0",
|
||||||
"node-dev": "^7.4.3",
|
"node-dev": "^7.4.3",
|
||||||
"prisma": "^4.1.0",
|
"prisma": "^4.1.0",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.7.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@koa/cors": "^3.3.0",
|
||||||
|
"@koa/router": "^12.0.0",
|
||||||
"@prisma/client": "^4.1.0",
|
"@prisma/client": "^4.1.0",
|
||||||
"axios": "^0.27.2",
|
"bcrypt": "^5.0.1",
|
||||||
"bluebird": "^3.7.2",
|
"bluebird": "^3.7.2",
|
||||||
"bunyan": "^1.8.15",
|
"bunyan": "^1.8.15",
|
||||||
"cors": "^2.8.5",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"express": "^4.18.1",
|
"koa": "^2.13.4",
|
||||||
"lodash": "^4.17.21"
|
"koa-body": "^5.0.0",
|
||||||
|
"koa-passport": "^5.0.0",
|
||||||
|
"koa-session": "^6.2.0",
|
||||||
|
"koa-websocket": "^7.0.0",
|
||||||
|
"koa-ws": "^1.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"passport-local": "^1.0.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"koa-passport": {
|
||||||
|
"passport": "^0.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
// include for node docker image
|
||||||
|
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||||
previewFeatures = ["interactiveTransactions"]
|
previewFeatures = ["interactiveTransactions"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,26 +18,32 @@ model Listing {
|
|||||||
wantId String @db.Uuid
|
wantId String @db.Uuid
|
||||||
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
offers Offer[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([seller])
|
@@index([seller])
|
||||||
|
@@map("listings")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
discogs String? @unique
|
email String @unique
|
||||||
data Json
|
password String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
wants Want[]
|
wants Want[]
|
||||||
|
offers Offer[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
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 Int
|
||||||
data Json
|
data Json
|
||||||
|
|
||||||
userId String @db.Uuid
|
userId String @db.Uuid
|
||||||
@@ -45,7 +53,36 @@ model Want {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
listings Listing[]
|
listings Listing[]
|
||||||
|
offers Offer[]
|
||||||
|
|
||||||
@@unique([userId, release])
|
@@unique([userId, release])
|
||||||
@@index([release])
|
@@index([release])
|
||||||
|
|
||||||
|
@@map("wants")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Offer {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
|
||||||
|
status String
|
||||||
|
|
||||||
|
item Decimal @db.Money
|
||||||
|
// markup Decimal @db.Money
|
||||||
|
shipping Decimal @db.Money
|
||||||
|
total Decimal @db.Money
|
||||||
|
comparison Decimal @db.Money
|
||||||
|
|
||||||
|
listingId Int
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id])
|
||||||
|
|
||||||
|
userId String @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
wantId String @db.Uuid
|
||||||
|
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// only one offer per want for a user
|
||||||
|
// @@unique([userId, wantId])
|
||||||
|
@@unique([listingId])
|
||||||
|
@@map("offers")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'assert/strict';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import createServices from './services';
|
||||||
|
import createLog from './log';
|
||||||
|
|
||||||
|
const log = createLog();
|
||||||
|
log.level('debug');
|
||||||
|
const services = createServices(log);
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'http://localhost:41337',
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let server;
|
||||||
|
before(function setup() {
|
||||||
|
server = services.api.listen();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(function teardown(done) {
|
||||||
|
server.close(done)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api', function apiTests() {
|
||||||
|
describe.skip('auth', function authTests() {
|
||||||
|
it('listens', async function listenTest() {
|
||||||
|
const res = await api.get('/health');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register', async function register() {
|
||||||
|
const body = {
|
||||||
|
email: `test+${Date.now()}@user.com`,
|
||||||
|
password: 'woaaah theres no limit',
|
||||||
|
};
|
||||||
|
const { data } = await api.post('/auth/register', body);
|
||||||
|
assert.equal(data.email, body.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login', async function login() {
|
||||||
|
const body = {
|
||||||
|
email: `test+${Date.now()}@user.com`,
|
||||||
|
password: 'woaaah theres no limit',
|
||||||
|
};
|
||||||
|
await api.post('/auth/register', body);
|
||||||
|
const res = await api.post('/auth/login', body);
|
||||||
|
assert(res.headers['set-cookie']);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offers', function offerTests() {
|
||||||
|
it('create offers', async function createOffersTest() {
|
||||||
|
const { data: bundles } = await api.get('/bundles');
|
||||||
|
const listingIds = bundles[0].items.map(i => i.id);
|
||||||
|
const { data } = await api.post('/offers', { listingIds, shipping: 50.0, baseShipping: 15.0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
import koa from 'koa';
|
||||||
|
import koaWebsocket from 'koa-websocket';
|
||||||
|
import bodyParser from 'koa-body';
|
||||||
|
import session from 'koa-session';
|
||||||
|
import koaRouter from '@koa/router'
|
||||||
|
import cors from '@koa/cors';
|
||||||
|
|
||||||
|
import { groupBy, sortBy } from 'lodash';
|
||||||
|
import Bluebird from 'bluebird';
|
||||||
|
|
||||||
|
import type Log from 'bunyan';
|
||||||
|
import type { Listing, User, Want, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { Pg } from './pg';
|
||||||
|
import routerAuth from './routers/router.auth';
|
||||||
|
import { Events } from './events';
|
||||||
|
|
||||||
|
export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
|
||||||
|
const PORT = 41337;
|
||||||
|
const WS_PORT = 40055;
|
||||||
|
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
|
||||||
|
|
||||||
|
const app = koaWebsocket(new koa());
|
||||||
|
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err) {
|
||||||
|
log.error(err);
|
||||||
|
ctx.status = err.statusCode || err.status || 500;
|
||||||
|
ctx.body = {
|
||||||
|
error: err.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = koaRouter();
|
||||||
|
|
||||||
|
function listen() {
|
||||||
|
const server = app.listen(PORT);
|
||||||
|
log.info({ PORT }, 'listening');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors({ credentials: true }));
|
||||||
|
app.use(bodyParser())
|
||||||
|
|
||||||
|
app.keys = [SECRET];
|
||||||
|
app.use(session(app));
|
||||||
|
|
||||||
|
const { authRouter, passport } = routerAuth(log, pg);
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session())
|
||||||
|
app.use(authRouter.routes());
|
||||||
|
|
||||||
|
router.get('/health', async function health(ctx) {
|
||||||
|
ctx.status = 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/acp/bundles', async function bundlesGet(ctx, next) {
|
||||||
|
const bundles = await offers.bundles();
|
||||||
|
ctx.response.body = bundles;
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/acp/offers', async function acpOffers(ctx, next) {
|
||||||
|
const offers = await pg.offersAdmin();
|
||||||
|
|
||||||
|
const grouped = groupBy(offers, o => o.listing.data.seller.id);
|
||||||
|
|
||||||
|
const bundles = sortBy(Object.values(grouped).map(offers => {
|
||||||
|
const shipping = offers[0]?.listing.data.seller.shipping;
|
||||||
|
const bundle = {
|
||||||
|
username: offers[0]?.listing.data.seller.username,
|
||||||
|
seller: offers[0]?.listing.data.seller.id,
|
||||||
|
shipsFrom: offers[0]?.listing.data.ships_from,
|
||||||
|
shipping,
|
||||||
|
offers,
|
||||||
|
mentionsAustralia: shipping.toLowerCase().includes('australia'),
|
||||||
|
mentionsWorldwide: shipping.toLowerCase().includes('world'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}), bundle => -bundle.offers.length);
|
||||||
|
|
||||||
|
ctx.response.body = bundles;
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/offers', async function postOffers(ctx, next) {
|
||||||
|
const { listingIds, shipping, baseShipping } = ctx.request.body;
|
||||||
|
|
||||||
|
ctx.assert(listingIds.length, 400, 'listingIds required');
|
||||||
|
ctx.assert([listingIds, shipping, baseShipping].every(i => i), 400, 'listingIds, shipping, baseShipping required');
|
||||||
|
|
||||||
|
ctx.response.body = await offers.generate(listingIds, shipping, baseShipping);
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/offer/accept', async function postOffers(ctx, next) {
|
||||||
|
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||||
|
ctx.response.body = await offers.accept(ctx);
|
||||||
|
})
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||||
|
const { releaseId } = ctx.request.body;
|
||||||
|
const user = await pg.userFindId(ctx.state.user.id);
|
||||||
|
const release = await discogs.release(releaseId);
|
||||||
|
const want = await pg.wantCreate(release, user);
|
||||||
|
events.emit(Events.WantAdded, want);
|
||||||
|
ctx.response.body = [want];
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/want/discogs', async function wantListPost(ctx, next) {
|
||||||
|
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||||
|
const { username } = ctx.request.body;
|
||||||
|
const user = await pg.userFindId(ctx.state.user.id);
|
||||||
|
const wants = await discogs.wantListSync(username, user);
|
||||||
|
wants.forEach(w => events.emit(Events.WantAdded, w));
|
||||||
|
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 wantPost(ctx, next) {
|
||||||
|
const { artist, title } = ctx.request.body;
|
||||||
|
const results = await discogs.search(artist, title);
|
||||||
|
ctx.response.body = results;
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/logout', async (ctx, next) => {
|
||||||
|
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||||
|
ctx.logout();
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(router.routes());
|
||||||
|
|
||||||
|
app.ws.use(passport.initialize());
|
||||||
|
app.ws.use(passport.session());
|
||||||
|
app.ws.use(async (ctx, next) => {
|
||||||
|
log.info('new ws connection', { user: ctx.state.user });
|
||||||
|
ctx.websocket.on('message', async function incoming(message) {
|
||||||
|
log.info(message.toString());
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message.toString());
|
||||||
|
log.info(parsed);
|
||||||
|
// const response = await rpc.receive(parsed);
|
||||||
|
// rpc.send(ws, response);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(e);
|
||||||
|
log.warn(message.toString());
|
||||||
|
// rpc.send(ws, [RpcMessageType.Error, e.message || e.msg])
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.websocket.on('close', function onClose() {
|
||||||
|
log.info('ws disconnected', { user: ctx.state.user });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
listen,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,11 +7,15 @@ import Bluebird from 'bluebird';
|
|||||||
import type Log from 'bunyan';
|
import type Log from 'bunyan';
|
||||||
import type { PrismaClient, Want, User, Listing } from '@prisma/client';
|
import type { PrismaClient, Want, User, Listing } from '@prisma/client';
|
||||||
|
|
||||||
import { logAxiosError } from './utils';
|
import { logAxiosError, wantDetails } from './utils';
|
||||||
import { Pg } from './pg';
|
import { Pg } from './pg';
|
||||||
|
|
||||||
export default function discogs(log: Log, pg: Pg) {
|
// margh
|
||||||
const TOKEN = 'wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
|
// vanderbabe
|
||||||
|
// jmcdermid1
|
||||||
|
|
||||||
|
export default function createDiscogs(log: Log, pg: Pg) {
|
||||||
|
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
|
||||||
const apiBase = 'https://api.discogs.com'
|
const apiBase = 'https://api.discogs.com'
|
||||||
const discogsBase = 'https://www.discogs.com'
|
const discogsBase = 'https://www.discogs.com'
|
||||||
|
|
||||||
@@ -31,24 +35,37 @@ export default function discogs(log: Log, pg: Pg) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function user(discogs: string) {
|
async function wantListSync(username: string, user: User): Promise<Want[]> {
|
||||||
const user = await pg.userFindName(discogs);
|
|
||||||
if (user) return user;
|
|
||||||
log.debug('fetching user', discogs);
|
|
||||||
const res = await api.get(`${apiBase}/users/${discogs}`);
|
|
||||||
return pg.userUpsert(res.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wantlist(user: User): Promise<Want[]> {
|
|
||||||
const stored = await pg.userWants(user);
|
|
||||||
if (stored.length) return stored;
|
|
||||||
return wantlistLatest(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wantlistLatest(user: User): Promise<Want[]> {
|
|
||||||
const { username } = user.data as { username: string };
|
|
||||||
const { data: { wants, pagination }} = await api.get(`${apiBase}/users/${username}/wants`);
|
const { data: { wants, pagination }} = await api.get(`${apiBase}/users/${username}/wants`);
|
||||||
return Bluebird.mapSeries(wants, async r => pg.wantUpsert(r, user));
|
const releases = await Promise.all(wants.map(want => release(want.id)));
|
||||||
|
|
||||||
|
log.info({ wants: wants.length, user: username, userId: user.id }, 'fetched discogs wantlist');
|
||||||
|
|
||||||
|
// TODO filter releases to be vinyl format only
|
||||||
|
|
||||||
|
// convert want object to actual release and ignore duplicate errors
|
||||||
|
const results = await Promise.allSettled(releases.map(release => pg.wantCreate(release, user)));
|
||||||
|
// @ts-ignore
|
||||||
|
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
||||||
|
|
||||||
|
log.info(additions);
|
||||||
|
|
||||||
|
return additions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function release(id: DiscogsRelease): Promise<any> {
|
||||||
|
const { data } = await api.get(`${apiBase}/releases/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(artist: string, title: string) {
|
||||||
|
const url = new URL(`${apiBase}/database/search`);
|
||||||
|
url.searchParams.append('artist', artist);
|
||||||
|
url.searchParams.append('title', title);
|
||||||
|
url.searchParams.append('type', 'release');
|
||||||
|
url.searchParams.append('format', 'vinyl');
|
||||||
|
const { data: { results } } = await api.get(url.href);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inventory(user: string) {
|
async function inventory(user: string) {
|
||||||
@@ -68,6 +85,10 @@ export default function discogs(log: Log, pg: Pg) {
|
|||||||
|
|
||||||
const unlisted = await pg.listingRmUnlisted(want, ids);
|
const unlisted = await pg.listingRmUnlisted(want, ids);
|
||||||
if (unlisted) log.info({ wantId: want.id, unlisted: unlisted }, 'missing listings removed');
|
if (unlisted) log.info({ wantId: want.id, unlisted: unlisted }, 'missing listings removed');
|
||||||
|
|
||||||
|
// preferentially take listings from sellers if we hit req limits
|
||||||
|
|
||||||
|
log.info({ listings: ids.length, ...wantDetails(want)}, 'found listings');
|
||||||
return Bluebird.mapSeries(ids, async id => listing(id, want));
|
return Bluebird.mapSeries(ids, async id => listing(id, want));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +99,7 @@ export default function discogs(log: Log, pg: Pg) {
|
|||||||
try {
|
try {
|
||||||
log.debug('fetching listing', id);
|
log.debug('fetching listing', id);
|
||||||
const res = await api.get(`${apiBase}/marketplace/listings/${id}`);
|
const res = await api.get(`${apiBase}/marketplace/listings/${id}`);
|
||||||
|
log.debug(res.data);
|
||||||
return pg.listingUpsert(res.data, want);
|
return pg.listingUpsert(res.data, want);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleApiError(e);
|
return handleApiError(e);
|
||||||
@@ -85,11 +107,11 @@ export default function discogs(log: Log, pg: Pg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
wantListSync,
|
||||||
wantlist,
|
|
||||||
wantlistLatest,
|
|
||||||
inventory,
|
inventory,
|
||||||
listings,
|
listings,
|
||||||
listing,
|
listing,
|
||||||
|
release,
|
||||||
|
search,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { setTimeout } from 'timers/promises';
|
||||||
|
|
||||||
|
import type Log from 'bunyan';
|
||||||
|
import type { Want } from '@prisma/client';
|
||||||
|
|
||||||
|
import { Pg } from './pg';
|
||||||
|
import { wantDetails } from './utils';
|
||||||
|
import routerAuth from './routers/router.auth';
|
||||||
|
|
||||||
|
export enum Events {
|
||||||
|
WantAdded = 'WantAdded',
|
||||||
|
StartupSync = 'StartupSync',
|
||||||
|
ListingsUpdated = 'ListingsUpdated',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function createEvents(log: Log, pg: Pg, discogs, offers) {
|
||||||
|
const events = new EventEmitter({ captureRejections: true });
|
||||||
|
|
||||||
|
async function populateListings(want: Want) {
|
||||||
|
try {
|
||||||
|
const listings = await discogs.listings(want);
|
||||||
|
return listings;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'rate limited') {
|
||||||
|
log.warn('rate limited, trying again in 60s');
|
||||||
|
await setTimeout(60 * 1000);
|
||||||
|
return populateListings(want);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.on('error', function eventError(e) {
|
||||||
|
log.error('event error', e);
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on(Events.WantAdded, async function wantAdded(want: Want) {
|
||||||
|
log.info({ event: Events.WantAdded, ...wantDetails(want) });
|
||||||
|
const listings = await populateListings(want);
|
||||||
|
log.info({ listings: listings.length, ...wantDetails(want) }, 'listings added');
|
||||||
|
events.emit(Events.ListingsUpdated);
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on(Events.StartupSync, async function wantAdded() {
|
||||||
|
const wants = await pg.wantsToSync();
|
||||||
|
log.info({ event: Events.StartupSync, wants: wants.length });
|
||||||
|
|
||||||
|
for (const want of wants) {
|
||||||
|
const listings = await populateListings(want);
|
||||||
|
log.info({ listings: listings.length, ...wantDetails(want) }, 'listings added');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ wants: wants.length }, 'finished syncing');
|
||||||
|
events.emit(Events.ListingsUpdated);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
events.on(Events.ListingsUpdated, async function listingsUpdated() {
|
||||||
|
const bundles = await offers.bundles();
|
||||||
|
log.info({ event: Events.ListingsUpdated, bundles: bundles.length });
|
||||||
|
|
||||||
|
// const bundle = {
|
||||||
|
// username: b[0]?.data.seller.username,
|
||||||
|
// seller: b[0]?.data.seller.id,
|
||||||
|
// shipsFrom: b[0]?.data.ships_from,
|
||||||
|
// total,
|
||||||
|
// currency: b[0]?.data.price.currency,
|
||||||
|
// shipping: '...',
|
||||||
|
// items,
|
||||||
|
// mentionsAustralia: shipping.toLowerCase().includes('australia'),
|
||||||
|
// mentionsWorldwide: shipping.toLowerCase().includes('world'),
|
||||||
|
// }
|
||||||
|
|
||||||
|
for (const bundle of bundles) {
|
||||||
|
log.info({ seller: bundle.seller, items: bundle.items.length }, 'generating offers...');
|
||||||
|
const listingIds = bundle.items.map(i => i.id);
|
||||||
|
const updatedOffers = await offers.generate(listingIds, 100, 30)
|
||||||
|
log.info({ offers: updatedOffers.length }, 'offers set');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('offers up to date');
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,112 @@
|
|||||||
|
import type Log from 'bunyan';
|
||||||
|
import type { PrismaClient, Want, User, Listing, Offer, } from '@prisma/client';
|
||||||
|
import groupBy from 'lodash/groupBy';
|
||||||
|
import sortBy from 'lodash/sortBy';
|
||||||
|
import { Context } from 'koa';
|
||||||
|
|
||||||
|
import { logAxiosError } from './utils';
|
||||||
|
import { Pg } from './pg';
|
||||||
|
|
||||||
|
export enum Status {
|
||||||
|
Pending = 'pending',
|
||||||
|
Accepted = 'accepted',
|
||||||
|
Declined = 'declined',
|
||||||
|
Ordered = 'ordered',
|
||||||
|
Complete = 'complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function createOffers(log: Log, pg: Pg) {
|
||||||
|
async function generate(listingIds: DiscogsListing[], bulkShipping: number, baseShipping: number) {
|
||||||
|
log.info('generateOffers', { listingIds, bulkShipping, baseShipping });
|
||||||
|
const listings = await pg.listingByIds(listingIds);
|
||||||
|
|
||||||
|
// log.info({ listings }, 'creating offers for listings')
|
||||||
|
|
||||||
|
const shipping = 10 + Math.random(); // yolt
|
||||||
|
|
||||||
|
// const shipping = bulkShipping / listings.length;
|
||||||
|
// how to deal with users who don't want all items in a bundle...
|
||||||
|
|
||||||
|
const offers = listings.map(l => ({
|
||||||
|
userId: l.want.user.id,
|
||||||
|
listingId: l.id,
|
||||||
|
wantId: l.want.id,
|
||||||
|
|
||||||
|
// sellerId: l.seller,
|
||||||
|
|
||||||
|
status: Status.Pending,
|
||||||
|
|
||||||
|
item: l.data.price.value,
|
||||||
|
shipping,
|
||||||
|
total: l.data.price.value + shipping,
|
||||||
|
|
||||||
|
// without mcrates...
|
||||||
|
comparison: l.data.price.value + baseShipping,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// we must sort the offers by price,
|
||||||
|
// sometimes a seller has multiples of a want
|
||||||
|
// to deal with this we just ignore duplicates on insert
|
||||||
|
// and ensure the cheapest one goes in first
|
||||||
|
const sorted = sortBy(offers, ['item']);
|
||||||
|
const inserts = await pg.offersCreate(sorted);
|
||||||
|
|
||||||
|
return inserts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accept(ctx: Context) {
|
||||||
|
const { id } = ctx.request.body;
|
||||||
|
ctx.assert(id, 401, 'no id supplied');
|
||||||
|
|
||||||
|
// check the user actually has this offer
|
||||||
|
const offer = await pg.userOfferFind(ctx.state.user, id);
|
||||||
|
return pg.offerSetStatus(offer.id, Status.Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bundles() {
|
||||||
|
const bundles = await pg.bundleCandidates();
|
||||||
|
|
||||||
|
interface ListingData extends Listing {
|
||||||
|
data: any,
|
||||||
|
want: { user: User },
|
||||||
|
};
|
||||||
|
|
||||||
|
return bundles.map((b: ListingData[]) => {
|
||||||
|
const total = b.reduce((acc, l) => acc + l.data.price.value, 0);
|
||||||
|
const items = b.map(l => ({
|
||||||
|
id: l.data.id,
|
||||||
|
description: l.data.release.description,
|
||||||
|
originalPrice: l.data.original_price.value,
|
||||||
|
originalCurrency: l.data.original_price.curr_abbr,
|
||||||
|
price: l.data.price.value,
|
||||||
|
currency: l.data.price.currency,
|
||||||
|
condition: l.data.condition,
|
||||||
|
uri: l.data.uri,
|
||||||
|
user: l.want.user,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const shipping = b[0]?.data.seller.shipping;
|
||||||
|
|
||||||
|
const bundle = {
|
||||||
|
username: b[0]?.data.seller.username,
|
||||||
|
seller: b[0]?.data.seller.id,
|
||||||
|
shipsFrom: b[0]?.data.ships_from,
|
||||||
|
total,
|
||||||
|
currency: b[0]?.data.price.currency,
|
||||||
|
shipping: '...',
|
||||||
|
items,
|
||||||
|
mentionsAustralia: shipping.toLowerCase().includes('australia'),
|
||||||
|
mentionsWorldwide: shipping.toLowerCase().includes('world'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
generate,
|
||||||
|
accept,
|
||||||
|
bundles,
|
||||||
|
};
|
||||||
|
}
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import type Log from 'bunyan';
|
||||||
|
import type { PrismaClient, Want, User, Listing, Offer, Prisma } from '@prisma/client';
|
||||||
|
import * as Bluebird from 'bluebird';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { Status } from './offers';
|
||||||
|
|
||||||
|
export interface UserCreateParams {
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pg {
|
||||||
|
userFindId: (id: UUID) => Promise<User>,
|
||||||
|
userFindEmail: (email: Email) => Promise<User>,
|
||||||
|
userCreate: (params: UserCreateParams) => Promise<User>,
|
||||||
|
userWants: (user: User) => Promise<Want[]>,
|
||||||
|
userOffers: (user: User) => Promise<Offer[]>,
|
||||||
|
userOfferFind: (user: User, id: UUID) => Promise<Offer>,
|
||||||
|
|
||||||
|
listingUpsert: (listing: Listing, want: Want) => Promise<Listing>,
|
||||||
|
listingFind: (id: DiscogsListing) => Promise<Listing>,
|
||||||
|
listingByIds: (ids: DiscogsListing[]) => Promise<Listing[]>,
|
||||||
|
listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise<number>,
|
||||||
|
|
||||||
|
wantCreate: (discogsWant: any, user: User) => Promise<Want>,
|
||||||
|
wantList: (user: User) => Promise<Want[]>,
|
||||||
|
wantsToSync: () => Promise<Want[]>,
|
||||||
|
|
||||||
|
bundleCandidates: () => Promise<Listing[][]>,
|
||||||
|
|
||||||
|
offersCreate: (offers: Offer[]) => Promise<Offer[]>,
|
||||||
|
offerSetStatus: (id: UUID, status: Status) => Promise<Offer>,
|
||||||
|
offersAdmin: () => Promise<Offer[]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
||||||
|
async function userFindId(id: UUID) {
|
||||||
|
return prisma.user.findUnique({ where: { id }});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userFindEmail(email: Email) {
|
||||||
|
return prisma.user.findUnique({ where: { email }});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userWants(user: User) {
|
||||||
|
return prisma.want.findMany({ where: { userId: user.id }});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userOffers(user: User) {
|
||||||
|
return prisma.offer.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: {
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
total: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userOfferFind(user: User, id: UUID) {
|
||||||
|
return prisma.offer.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
want: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userCreate(params: UserCreateParams) {
|
||||||
|
const hashRounds = 10;
|
||||||
|
const password = await bcrypt.hash(params.password, hashRounds);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
email: params.email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma.user.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userUpdate(params: UserCreateParams) {
|
||||||
|
const hashRounds = 10;
|
||||||
|
const password = await bcrypt.hash(params.password, hashRounds);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
email: params.email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { email: params.email },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wantList(user: User) {
|
||||||
|
return prisma.want.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wantsToSync() {
|
||||||
|
return prisma.want.findMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wantCreate(discogsRelease, user) {
|
||||||
|
const data: { userId: UUID, release: DiscogsListing, data: any } = {
|
||||||
|
data: discogsRelease,
|
||||||
|
release: discogsRelease.id,
|
||||||
|
userId: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma.want.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listingRmUnlisted(want: Want, currentListingIds: DiscogsListing[]) {
|
||||||
|
return prisma.$transaction(async (prisma) => {
|
||||||
|
log.debug({ currentListingIds });
|
||||||
|
|
||||||
|
const openOfferStatuses = [Status.Pending, Status.Accepted];
|
||||||
|
|
||||||
|
// can't delete a listing with an open offer
|
||||||
|
// so delete those offers first
|
||||||
|
const { count: offersRemoved } = await prisma.offer.deleteMany({
|
||||||
|
where: {
|
||||||
|
wantId: want.id,
|
||||||
|
listingId: {
|
||||||
|
notIn: currentListingIds,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: openOfferStatuses,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offersRemoved > 0) log.info({ offersRemoved }, 'open offers removed');
|
||||||
|
|
||||||
|
// now we have to filter listingIds with confirmed offers
|
||||||
|
// and get their listingIds to filter out
|
||||||
|
const confirmedListingIds = (await prisma.offer.findMany({
|
||||||
|
where: {
|
||||||
|
wantId: want.id,
|
||||||
|
status: {
|
||||||
|
notIn: openOfferStatuses,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
listingId: true,
|
||||||
|
}
|
||||||
|
})).map(r => r.listingId);
|
||||||
|
const removalIds = _.without(currentListingIds, ...confirmedListingIds);
|
||||||
|
|
||||||
|
// finally we delete listings that aren't related
|
||||||
|
// to a historical offer
|
||||||
|
const { count } = await prisma.listing.deleteMany({
|
||||||
|
where: {
|
||||||
|
wantId: want.id,
|
||||||
|
id: {
|
||||||
|
notIn: currentListingIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listingUpsert(listing, want) {
|
||||||
|
const values = {
|
||||||
|
id: listing.id,
|
||||||
|
wantId: want.id,
|
||||||
|
seller: listing.seller.id,
|
||||||
|
data: listing,
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma.listing.upsert({
|
||||||
|
where: {
|
||||||
|
id: listing.id
|
||||||
|
},
|
||||||
|
update: values,
|
||||||
|
create: values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listingFind(id: DiscogsListing) {
|
||||||
|
return prisma.listing.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
want: {
|
||||||
|
include: { user: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listingByIds(ids: DiscogsListing[]) {
|
||||||
|
return prisma.listing.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
want: {
|
||||||
|
include: { user: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bundleCandidates(): Promise<Listing[][]> {
|
||||||
|
const candidates = await prisma.$queryRaw`
|
||||||
|
select
|
||||||
|
seller, count(distinct("wantId")), array_agg(id) as listings
|
||||||
|
from
|
||||||
|
listings
|
||||||
|
where
|
||||||
|
seller not in (3709203)
|
||||||
|
group by
|
||||||
|
seller
|
||||||
|
order by
|
||||||
|
count desc
|
||||||
|
limit 5;
|
||||||
|
` as BundleCandidate[];
|
||||||
|
|
||||||
|
return Promise.all(candidates.map(async c => prisma.listing.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: c.listings }
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
want: {
|
||||||
|
include: { user: true },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
distinct: ['wantId'],
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function offersCreate(offers: Offer[]) {
|
||||||
|
return await prisma.$transaction(
|
||||||
|
offers.map(offer => prisma.offer.upsert({
|
||||||
|
where: {
|
||||||
|
listingId: offer.listingId,
|
||||||
|
},
|
||||||
|
create: offer,
|
||||||
|
update: _.omit(offer, ['status']),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function offerSetStatus(id: UUID, status: Status) {
|
||||||
|
return prisma.offer.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function offersAdmin() {
|
||||||
|
return prisma.offer.findMany({
|
||||||
|
include: {
|
||||||
|
listing: true,
|
||||||
|
user: true,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userFindId,
|
||||||
|
userFindEmail,
|
||||||
|
userCreate,
|
||||||
|
userWants,
|
||||||
|
userOffers,
|
||||||
|
userOfferFind,
|
||||||
|
|
||||||
|
wantCreate,
|
||||||
|
wantList,
|
||||||
|
wantsToSync,
|
||||||
|
listingUpsert,
|
||||||
|
listingFind,
|
||||||
|
listingRmUnlisted,
|
||||||
|
listingByIds,
|
||||||
|
|
||||||
|
bundleCandidates,
|
||||||
|
|
||||||
|
offersCreate,
|
||||||
|
offerSetStatus,
|
||||||
|
offersAdmin,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { KoaPassport } from 'koa-passport';
|
||||||
|
import { Strategy as LocalStrategy } from 'passport-local';
|
||||||
|
import Router from '@koa/router'
|
||||||
|
|
||||||
|
import type { Pg } from './../pg';
|
||||||
|
|
||||||
|
export default function authRouter(log, pg: Pg) {
|
||||||
|
const passport = new KoaPassport();
|
||||||
|
|
||||||
|
async function verifyPassword(user, password) {
|
||||||
|
const match = await bcrypt.compare(password, user.password);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
passport.use(new LocalStrategy(
|
||||||
|
{ usernameField: 'email' },
|
||||||
|
function localStrategy(email, password, done) {
|
||||||
|
pg.userFindEmail(email)
|
||||||
|
.then(async user => {
|
||||||
|
if (!user) return done(null, false);
|
||||||
|
const passwordMatch = await verifyPassword(user, password);
|
||||||
|
if (!passwordMatch) return done(null, false);
|
||||||
|
return done(null, user);
|
||||||
|
})
|
||||||
|
.catch(e => done(e, null));
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
passport.serializeUser((user, done) => done(null, user.id));
|
||||||
|
passport.deserializeUser((id, done) => {
|
||||||
|
pg.userFindId(id)
|
||||||
|
.then(u => done(null, u))
|
||||||
|
.catch(e => done(e, null));
|
||||||
|
})
|
||||||
|
|
||||||
|
const authRouter = new Router({ prefix: '/auth' });
|
||||||
|
|
||||||
|
authRouter.post('/login', async (ctx, next) => {
|
||||||
|
await passport.authenticate('local', (err, user, info, status) => {
|
||||||
|
ctx.assert(user, 401, 'incorrect login');
|
||||||
|
ctx.login(user);
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = {
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
})(ctx, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get('/login', async (ctx, next) => {
|
||||||
|
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/register', async (ctx, next) => {
|
||||||
|
const { discogs, email, password } = ctx.request.body;
|
||||||
|
// fetch discogs data if they have it
|
||||||
|
const params = { discogs, email, password, data: {} };
|
||||||
|
const user = await pg.userCreate(params)
|
||||||
|
ctx.login(user);
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { authRouter, passport };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
import createDiscogs from './discogs';
|
||||||
|
import createOffers from './offers';
|
||||||
|
import createLog from './log';
|
||||||
|
import createApi from './api';
|
||||||
|
import createEvents from './events';
|
||||||
|
import createPg from './pg';
|
||||||
|
|
||||||
|
export default function creates(log) {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const pg = createPg(log, prisma);
|
||||||
|
const discogs = createDiscogs(log, pg);
|
||||||
|
const offers = createOffers(log, pg);
|
||||||
|
const events = createEvents(log, pg, discogs, offers);
|
||||||
|
const api = createApi(log, pg, discogs, offers, events);
|
||||||
|
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
discogs,
|
||||||
|
events,
|
||||||
|
pg,
|
||||||
|
prisma,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Want } from '@prisma/client';
|
||||||
|
|
||||||
export function logAxiosError(e) {
|
export function logAxiosError(e) {
|
||||||
console.warn({
|
console.warn({
|
||||||
status: e.response.status,
|
status: e.response.status,
|
||||||
@@ -11,3 +13,14 @@ export function logAxiosError(e) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wantDetails(want: Want) {
|
||||||
|
const data = want.data as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title,
|
||||||
|
release: data.id,
|
||||||
|
artist: data.artists_sort,
|
||||||
|
id: want.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
"types": [
|
"types": [
|
||||||
"./index.d.ts"
|
"./index.d.ts",
|
||||||
|
"mocha"
|
||||||
], /* Specify type package names to be included without being referenced in a source file. */
|
], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
@@ -101,5 +102,10 @@
|
|||||||
/* Completeness */
|
/* Completeness */
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"ts-node": {
|
||||||
|
// strict mode gtfo
|
||||||
|
"transpileOnly": true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
parserOpts: { strictMode: false },
|
||||||
|
plugins: ['@babel/plugin-transform-react-jsx-source']
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
extends: [
|
||||||
|
"preact",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
ignorePatterns: [
|
||||||
|
"build/"
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
// prevents stupid complaints a la
|
||||||
|
// (req) {
|
||||||
|
// req.something = x;
|
||||||
|
// }
|
||||||
|
'no-param-reassign': [2, { props: false }],
|
||||||
|
'no-multi-spaces': [0],
|
||||||
|
'max-len': ['error', 120],
|
||||||
|
'import/no-extraneous-dependencies': [0],
|
||||||
|
'prefer-arrow-callback': [0],
|
||||||
|
'arrow-body-style': [0],
|
||||||
|
'no-return-assign': 0,
|
||||||
|
'no-console': [0],
|
||||||
|
// i like loops
|
||||||
|
'no-plusplus': [0],
|
||||||
|
|
||||||
|
'no-await-in-loop': [0],
|
||||||
|
indent: ['error', 4],
|
||||||
|
'keyword-spacing': ['error'],
|
||||||
|
'key-spacing': ['error'],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
/build
|
/build
|
||||||
/*.log
|
/*.log
|
||||||
|
|
||||||
|
size-plugin.json
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
This application uses Discogs’ API but is not affiliated with, sponsored or endorsed by Discogs. ‘Discogs’ is a trademark of Zink Media, LLC.
|
||||||
|
Data provided by Discogs.
|
||||||
Generated
+51
-13
@@ -9,14 +9,17 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"preact": "^10.10.0",
|
"preact": "^10.10.0",
|
||||||
"preact-render-to-string": "^5.2.1",
|
"preact-render-to-string": "^5.2.1",
|
||||||
"preact-router": "^3.2.1",
|
"preact-router": "^3.2.1",
|
||||||
|
"react-feather": "^2.0.10",
|
||||||
"react-redux": "^8.0.2",
|
"react-redux": "^8.0.2",
|
||||||
"redux": "^4.2.0",
|
"redux": "^4.2.0",
|
||||||
"unistore": "^3.5.2"
|
"unistore": "^3.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/plugin-transform-react-jsx-source": "^7.18.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||||
"@typescript-eslint/parser": "^5.30.6",
|
"@typescript-eslint/parser": "^5.30.6",
|
||||||
"eslint": "^8.20.0",
|
"eslint": "^8.20.0",
|
||||||
@@ -1596,6 +1599,21 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||||
|
"version": "7.18.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.18.6.tgz",
|
||||||
|
"integrity": "sha512-utZmlASneDfdaMh0m/WausbjUjEdGrQJz0vFK93d7wD3xf5wBtX219+q6IlCNZeguIcxS2f/CvLZrlLSvSHQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.18.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/plugin-transform-regenerator": {
|
"node_modules/@babel/plugin-transform-regenerator": {
|
||||||
"version": "7.18.6",
|
"version": "7.18.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
|
||||||
@@ -11860,8 +11878,7 @@
|
|||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
@@ -12778,7 +12795,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -15798,7 +15814,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
@@ -15808,8 +15823,7 @@
|
|||||||
"node_modules/prop-types/node_modules/react-is": {
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
@@ -16113,6 +16127,17 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-feather": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
||||||
@@ -22241,6 +22266,15 @@
|
|||||||
"@babel/types": "^7.18.6"
|
"@babel/types": "^7.18.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@babel/plugin-transform-react-jsx-source": {
|
||||||
|
"version": "7.18.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.18.6.tgz",
|
||||||
|
"integrity": "sha512-utZmlASneDfdaMh0m/WausbjUjEdGrQJz0vFK93d7wD3xf5wBtX219+q6IlCNZeguIcxS2f/CvLZrlLSvSHQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.18.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@babel/plugin-transform-regenerator": {
|
"@babel/plugin-transform-regenerator": {
|
||||||
"version": "7.18.6",
|
"version": "7.18.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz",
|
||||||
@@ -30310,8 +30344,7 @@
|
|||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"lodash.debounce": {
|
"lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
@@ -31062,8 +31095,7 @@
|
|||||||
"object-assign": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"object-copy": {
|
"object-copy": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -33326,7 +33358,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
@@ -33336,8 +33367,7 @@
|
|||||||
"react-is": {
|
"react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
"dev": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -33580,6 +33610,14 @@
|
|||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-feather": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==",
|
||||||
|
"requires": {
|
||||||
|
"prop-types": "^15.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-is": {
|
"react-is": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
||||||
|
|||||||
+4
-11
@@ -5,28 +5,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "preact build",
|
"build": "preact build",
|
||||||
"start": "preact watch",
|
"start": "preact --no-clear watch",
|
||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"extends": [
|
|
||||||
"preact",
|
|
||||||
"plugin:@typescript-eslint/recommended"
|
|
||||||
],
|
|
||||||
"ignorePatterns": [
|
|
||||||
"build/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"preact": "^10.10.0",
|
"preact": "^10.10.0",
|
||||||
"preact-render-to-string": "^5.2.1",
|
"preact-render-to-string": "^5.2.1",
|
||||||
"preact-router": "^3.2.1",
|
"preact-router": "^3.2.1",
|
||||||
|
"react-feather": "^2.0.10",
|
||||||
"react-redux": "^8.0.2",
|
"react-redux": "^8.0.2",
|
||||||
"redux": "^4.2.0",
|
"redux": "^4.2.0",
|
||||||
"unistore": "^3.5.2"
|
"unistore": "^3.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/plugin-transform-react-jsx-source": "^7.18.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||||
"@typescript-eslint/parser": "^5.30.6",
|
"@typescript-eslint/parser": "^5.30.6",
|
||||||
"eslint": "^8.20.0",
|
"eslint": "^8.20.0",
|
||||||
|
|||||||
+18
-4
@@ -1,9 +1,23 @@
|
|||||||
import axios from 'axios';
|
import { route } from 'preact-router';
|
||||||
|
import http from './http';
|
||||||
|
|
||||||
const actions = store => ({
|
const actions = store => ({
|
||||||
getBundles: async () => {
|
setWs: function setWs(state, ws) {
|
||||||
const { data: bundles } = await axios.get('http://localhost:41337/bundles');
|
return { ws };
|
||||||
return { bundles };
|
},
|
||||||
|
|
||||||
|
setUser: function setUser(state, user) {
|
||||||
|
return { user };
|
||||||
|
},
|
||||||
|
|
||||||
|
setOffers: function setOffers(state, offers) {
|
||||||
|
return { offers };
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async function logout() {
|
||||||
|
await http.postLogout();
|
||||||
|
route('/login');
|
||||||
|
return { user: null };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+38
-13
@@ -1,30 +1,55 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { Route, Router } from 'preact-router';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import { Provider } from 'unistore/preact'
|
import { Route, Router, route } from 'preact-router';
|
||||||
|
import { Provider, connect } from 'unistore/preact'
|
||||||
|
|
||||||
import mcratesStore from './store';
|
import createStore from './store';
|
||||||
import actions from './actions';
|
import createWs from './ws';
|
||||||
|
import http from './http';
|
||||||
|
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
|
|
||||||
import Home from './routes/home';
|
import Home from './routes/home';
|
||||||
import Profile from './routes/profile';
|
import Bundles from './routes/bundles';
|
||||||
|
import Admin from './routes/acp';
|
||||||
|
import Login from './routes/login';
|
||||||
|
import Search from './routes/search';
|
||||||
|
import List from './routes/list';
|
||||||
|
|
||||||
const store = mcratesStore();
|
const store = createStore();
|
||||||
|
const ws = createWs(store);
|
||||||
|
|
||||||
console.log(Home);
|
ws.connect();
|
||||||
|
|
||||||
const App = () => (
|
async function init() {
|
||||||
|
const [error, user] = await http.getLogin();
|
||||||
|
if (error) return route('/login');
|
||||||
|
route('/bundles');
|
||||||
|
store.setState({ user });
|
||||||
|
store.setState({ ws });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<div id="app">
|
<main id="app">
|
||||||
<Header />
|
<Header />
|
||||||
<Router>
|
<Router>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route path="/profile/" component={Profile} user="me" />
|
<Route path="/login" component={Login} mode="login" />
|
||||||
<Route path="/profile/:user" component={Profile} />
|
<Route path="/register" component={Login} mode="register" />
|
||||||
|
<Route path="/search" component={Search} />
|
||||||
|
<Route path="/list" component={List} />
|
||||||
|
|
||||||
|
{/* ACP */}
|
||||||
|
<Route path="/bundles" component={Bundles} />
|
||||||
|
<Route path="/offers/acp" component={Admin} />
|
||||||
</Router>
|
</Router>
|
||||||
</div>
|
</main>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { ExternalLink, Check } from 'react-feather';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import http from '../http';
|
||||||
|
|
||||||
|
export default function Offer(offer) {
|
||||||
|
const { want: { data: details } } = offer;
|
||||||
|
|
||||||
|
const [accepted, setAccepted] = useState(offer.status !== 'pending');
|
||||||
|
|
||||||
|
async function onClick() {
|
||||||
|
const [error] = await http.postOfferAccept(offer.id);
|
||||||
|
if (error) return false;
|
||||||
|
setAccepted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = `${accepted ? 'accepted' : ''}`;
|
||||||
|
const btnText = accepted
|
||||||
|
? offer.status
|
||||||
|
: 'Accept';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="offer">
|
||||||
|
<section>
|
||||||
|
<h2>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h2>
|
||||||
|
<h5 class='alt-text' >{details.year}</h5>
|
||||||
|
<h5 class='alt-text'>{details.formats[0].descriptions.join(' ')}</h5>
|
||||||
|
<h5 class='number'>${offer.total}</h5>
|
||||||
|
</section>
|
||||||
|
<img src={details.thumb} />
|
||||||
|
<button onClick={onClick} disabled={accepted} class={classes} >{btnText}</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Square, CheckSquare } from 'react-feather';
|
||||||
|
import { useState, useReducer } from 'preact/hooks';
|
||||||
|
|
||||||
|
import http from '../http';
|
||||||
|
|
||||||
|
function Item(item, state, dispatch) {
|
||||||
|
const icon = state.checkedIds.includes(item.id)
|
||||||
|
? <CheckSquare onClick={() => dispatch({ id: item.id})} />
|
||||||
|
: <Square onClick={() => dispatch({ id: item.id})} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<a href={item.uri} target="_blank" >
|
||||||
|
<h4>{item.description}</h4>
|
||||||
|
</a>
|
||||||
|
<h5 class='alt-text'>{item.price.toFixed(2)} {item.currency}</h5>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reducer = (state, action) => {
|
||||||
|
if (state.checkedIds.includes(action.id)) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
checkedIds: state.checkedIds.filter(id => id !== action.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
checkedIds: [
|
||||||
|
...state.checkedIds,
|
||||||
|
action.id
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Bundle(bundle) {
|
||||||
|
const initialState = { checkedIds: bundle.items.map(i => i.id) }
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState)
|
||||||
|
|
||||||
|
const [shipping, setShipping] = useState(0);
|
||||||
|
const [baseShipping, setBaseShipping] = useState(0);
|
||||||
|
|
||||||
|
const items = bundle.items.map(item => Item(item, state, dispatch));
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const [error, offers] = await http.postOffers(state.checkedIds, shipping, baseShipping);
|
||||||
|
console.log(error, offers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="bundle">
|
||||||
|
<section>
|
||||||
|
<h2>{bundle.username} - {bundle.seller}</h2>
|
||||||
|
<h3>{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}</h3>
|
||||||
|
<h4>Ships: {bundle.mentionsAustralia && 'AU'} {bundle.mentionsWorldwide && 'WORLD'}</h4>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<form action="">
|
||||||
|
<div class="item">
|
||||||
|
<label for="shipping">Full Shipping Cost</label>
|
||||||
|
<input type="number" name="shipping" id="shipping" value={shipping} onChange={(e) => setShipping(Number.parseFloat((e.target as HTMLInputElement).value))} step="0.01" min="0" required />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<label for="baseShipping">Base Shipping Cost</label>
|
||||||
|
<input type="number" name="baseShipping" id="baseShipping" value={baseShipping} onChange={(e) => setBaseShipping(Number.parseFloat((e.target as HTMLInputElement).value))} step="0.01" min="0" required />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<input type="submit" value="Generate Offers" onClick={(e) => submit(e)} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class='items'>
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,55 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { Link } from 'preact-router/match';
|
import { route } from 'preact-router';
|
||||||
|
import { connect } from 'unistore/preact'
|
||||||
|
|
||||||
const Header = () => (
|
import http from './../http';
|
||||||
<header class={'header'}>
|
import actions from './../actions';
|
||||||
<h1>Mates Crates</h1>
|
|
||||||
|
const Header = ({ user, logout }) => {
|
||||||
|
|
||||||
|
function logoutClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = () => (
|
||||||
<nav>
|
<nav>
|
||||||
<Link activeClassName={'active'} href="/">Home</Link>
|
<a href="/">Offers</a>
|
||||||
<Link activeClassName={'active'} href="/profile">Me</Link>
|
<a href='/list'>List</a>
|
||||||
<Link activeClassName={'active'} href="/profile/john">John</Link>
|
<a href="/search">Search</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
export default Header;
|
const anonNav = () => (
|
||||||
|
<nav>
|
||||||
|
<a href="/login">Login</a>
|
||||||
|
<a href="/register">Register</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminNav = () => (
|
||||||
|
<nav>
|
||||||
|
<a href="/bundles">Bundles</a>
|
||||||
|
<a href='/offers/acp'>Offers ACP</a>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
|
||||||
|
const userNav = () => (
|
||||||
|
<nav>
|
||||||
|
<a href="/account">Account</a>
|
||||||
|
<a href="/auth/logout" onClick={logoutClick}>Logout</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
{user ? nav() : anonNav()}
|
||||||
|
{user ? adminNav() : null}
|
||||||
|
{user ? userNav() : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore unistore types
|
||||||
|
export default connect(['user'], actions)(Header);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { ExternalLink, Check } from 'react-feather';
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import http from '../http';
|
||||||
|
|
||||||
|
export default function Offer(offer) {
|
||||||
|
const { want: { data: details } } = offer;
|
||||||
|
|
||||||
|
const [accepted, setAccepted] = useState(offer.status !== 'pending');
|
||||||
|
|
||||||
|
async function onClick() {
|
||||||
|
const [error] = await http.postOfferAccept(offer.id);
|
||||||
|
if (error) return false;
|
||||||
|
setAccepted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = `${accepted ? 'accepted' : ''}`;
|
||||||
|
const btnText = accepted
|
||||||
|
? offer.status
|
||||||
|
: 'Accept';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="offer">
|
||||||
|
<section>
|
||||||
|
<h2>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h2>
|
||||||
|
<h5 class='alt-text' >{details.year}</h5>
|
||||||
|
<h5 class='alt-text'>{details.formats[0].descriptions.join(' ')}</h5>
|
||||||
|
<h5 class='number'>mcrates price: ${offer.total}</h5>
|
||||||
|
<h5 class='number'>shipped by itself: ${offer.comparison}</h5>
|
||||||
|
</section>
|
||||||
|
<img src={details.thumb} />
|
||||||
|
<button onClick={onClick} disabled={accepted} class={classes} >{btnText}</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
function Want(want) {
|
||||||
|
console.log(want);
|
||||||
|
const r = want.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={r.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{r.artists_sort} - {r.title}</h3>
|
||||||
|
<h5 class='alt-text' >{r.year}</h5>
|
||||||
|
<h5 class='alt-text'>{r.formats[0].descriptions.join(' ')}</h5>
|
||||||
|
</div>
|
||||||
|
<img src={r.thumb} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Want;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
type ApiResponse = [string | null, any | null];
|
||||||
|
|
||||||
|
declare module 'axios' {
|
||||||
|
export interface AxiosInstance {
|
||||||
|
request(config: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
get(url: string, config?: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
delete(url: string, config?: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
head(url: string, config?: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
post(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
put(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
patch(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSuccess(response) {
|
||||||
|
return [null, response.data];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(error) {
|
||||||
|
if (error.response) return [error.response.data.error, null];
|
||||||
|
if (error.request) return ['Network Error', null];
|
||||||
|
return ['Unknown Error', null];
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(onSuccess, onError);
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
getBundles: async function getBundles() {
|
||||||
|
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(artist, title) {
|
||||||
|
return api.post('/want/search', { artist, title });
|
||||||
|
},
|
||||||
|
|
||||||
|
postWant: async function postWant(releaseId) {
|
||||||
|
return api.post('/want', { releaseId });
|
||||||
|
},
|
||||||
|
|
||||||
|
postDiscogs: async function postDiscogs(username) {
|
||||||
|
return api.post('/want/discogs', { username });
|
||||||
|
},
|
||||||
|
|
||||||
|
getLogin: async function getLogin() {
|
||||||
|
return api.get('/auth/login')
|
||||||
|
},
|
||||||
|
|
||||||
|
postLogin: async function postLogin(email, password) {
|
||||||
|
return api.post('/auth/login', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
// discogs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
postLogout: async function postLogout() {
|
||||||
|
return api.post('/auth/logout', {});
|
||||||
|
},
|
||||||
|
|
||||||
|
postRegister: async function postRegister(email, password) {
|
||||||
|
return api.post('/auth/register', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
postOffers: async function postOffers(listingIds, shipping, baseShipping) {
|
||||||
|
return api.post('/offers', {
|
||||||
|
listingIds,
|
||||||
|
shipping,
|
||||||
|
baseShipping,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
postOfferAccept: async function postOffers(id) {
|
||||||
|
return api.post('/offer/accept', {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
postOfferStatus: async function postOfferStatus(id, status) {
|
||||||
|
return api.post('/acp/offer/status', {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default actions;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { connect } from 'unistore/preact'
|
||||||
|
|
||||||
|
import http from './../http';
|
||||||
|
import AcpOffer from './../components/acp.offer';
|
||||||
|
|
||||||
|
import { Status } from './../../../api/src/offers';
|
||||||
|
|
||||||
|
function ItemEl({ item, ws }) {
|
||||||
|
console.log(item);
|
||||||
|
console.log(ws);
|
||||||
|
const statuses = Object.entries(Status).map(([label, value]) => (
|
||||||
|
<option value={value}>{label}</option>
|
||||||
|
));
|
||||||
|
|
||||||
|
function onChange(e) {
|
||||||
|
console.log(item.id, e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<a href={item.listing.data.uri} target="_blank" >
|
||||||
|
<h4>{item.listing.data.release.description}</h4>
|
||||||
|
</a>
|
||||||
|
<h5 class='alt-text'>{item.total}</h5>
|
||||||
|
<h5>{item.status}</h5>
|
||||||
|
<select onChange={onChange}>
|
||||||
|
{statuses}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = connect(['ws'], {}, ItemEl);
|
||||||
|
|
||||||
|
function SellerGroup(group) {
|
||||||
|
return (
|
||||||
|
<div class="bundle">
|
||||||
|
<section>
|
||||||
|
<h2>{group.username} - {group.seller}</h2>
|
||||||
|
<h3>{group.offers.length} offers</h3>
|
||||||
|
<h4>Ships: {group.mentionsAustralia && 'AU'} {group.mentionsWorldwide && 'WORLD'}</h4>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class='items'>
|
||||||
|
{group.offers.map(i => <Item item={i} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const [offers, setOffers] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getOffers() {
|
||||||
|
const [error, offers] = await http.getOffersAcp();
|
||||||
|
if (error) return console.error(error);
|
||||||
|
setOffers(offers);
|
||||||
|
}
|
||||||
|
getOffers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!offers.length) {
|
||||||
|
return (
|
||||||
|
<div class={'offers'}>
|
||||||
|
loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class='bundles'>
|
||||||
|
{offers.map(SellerGroup)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import http from './../http';
|
||||||
|
import Bundle from './../components/bundle';
|
||||||
|
|
||||||
|
function Bundles() {
|
||||||
|
const [bundles, setBundles] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getBundles() {
|
||||||
|
const [error, bundles] = await http.getBundles();
|
||||||
|
if (error) return console.error(error);
|
||||||
|
setBundles(bundles);
|
||||||
|
}
|
||||||
|
getBundles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!bundles.length) {
|
||||||
|
return (
|
||||||
|
<div class={'bundles'}>
|
||||||
|
loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={'bundles'}>
|
||||||
|
{bundles.map(Bundle)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Bundles;
|
||||||
+20
-31
@@ -1,49 +1,38 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { route } from 'preact-router';
|
||||||
import { connect } from 'unistore/preact'
|
import { connect } from 'unistore/preact'
|
||||||
|
|
||||||
|
import http from './../http';
|
||||||
import actions from './../actions';
|
import actions from './../actions';
|
||||||
|
|
||||||
// import Bundle from './../components/bundle';
|
import Offer from './../components/offer';
|
||||||
|
|
||||||
interface Props {
|
function Home({ offers, setOffers }) {
|
||||||
bundles: any[];
|
useEffect(() => {
|
||||||
getBundles: () => void,
|
async function getOffers() {
|
||||||
}
|
const [error, offers] = await http.getOffers();
|
||||||
|
if (error) return console.error(error);
|
||||||
|
setOffers(offers);
|
||||||
|
if (offers.length === 0) route('/search');
|
||||||
|
}
|
||||||
|
getOffers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
function Bundle(bundle) {
|
if (!offers.length) {
|
||||||
return (
|
return (
|
||||||
<div class="bundle">
|
<div class={'offers'}>
|
||||||
<h2>{bundle.username} - {bundle.seller}</h2>
|
|
||||||
<h3>{bundle.total} {bundle.currency}</h3>
|
|
||||||
{bundle.items.map(i => (
|
|
||||||
<div key={i.id}>
|
|
||||||
<a href={i.uri}>
|
|
||||||
<h4>{i.description}</h4>
|
|
||||||
</a>
|
|
||||||
<h5>{i.price} {i.currency}</h5>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function Home({ bundles, getBundles }: Props) {
|
|
||||||
if (!bundles.length) {
|
|
||||||
getBundles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={'home'}>
|
|
||||||
loading...
|
loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={'home'}>
|
<div class={'offers'}>
|
||||||
{bundles.map(Bundle)}
|
{offers.map(Offer)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect('bundles', actions)(Home);
|
export default connect(['offers'], actions)(Home);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
// import style from './style.less';
|
||||||
|
import http from './../http';
|
||||||
|
import Want from './../components/want';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: any,
|
||||||
|
wants: any[],
|
||||||
|
getUserOffers: (userId: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const List = ({ user }: Props) => {
|
||||||
|
const [wants, setOffers] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getWants() {
|
||||||
|
const [error, wants] = await http.getWants();
|
||||||
|
if (error) return console.error(error);
|
||||||
|
setOffers(wants);
|
||||||
|
}
|
||||||
|
getWants();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
if (!wants.length) {
|
||||||
|
return <div> loading... </div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="list">
|
||||||
|
{wants.map(Want)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default List;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { route } from 'preact-router';
|
||||||
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
|
import { connect } from 'unistore/preact'
|
||||||
|
|
||||||
|
import http from './../http';
|
||||||
|
import actions from './../actions';
|
||||||
|
|
||||||
|
const Login = ({ setUser, mode }) => {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function postLogin() {
|
||||||
|
const [error, user] = await http.postLogin(email, password);
|
||||||
|
if (error) return setError(error);
|
||||||
|
route('/');
|
||||||
|
setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postRegister() {
|
||||||
|
const [error, user] = await http.postRegister(email, password);
|
||||||
|
if (error) return setError(error);
|
||||||
|
route('/search');
|
||||||
|
setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mode === 'login') return postLogin()
|
||||||
|
if (mode === 'register') return postRegister();
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRef = useRef<HTMLInputElement>()
|
||||||
|
useEffect(() => {
|
||||||
|
emailRef.current.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
const errorEl = error
|
||||||
|
? <div class='alt-text'>{error}</div>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (mode === 'register') return (
|
||||||
|
<div class="login">
|
||||||
|
{errorEl}
|
||||||
|
<form action="">
|
||||||
|
<div class="item">
|
||||||
|
<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)} />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<label for="password">password</label>
|
||||||
|
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<label for="password">confirm password</label>
|
||||||
|
<input type="password" name="password" id="password" value={confirmPassword} required onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<input type="submit" value="register" onClick={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="login">
|
||||||
|
{errorEl}
|
||||||
|
<form action="">
|
||||||
|
<div class="item">
|
||||||
|
<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)} />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<label for="password">password</label>
|
||||||
|
<input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<input type="submit" value="login" onClick={handleSubmit} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginForm = connect([], actions)(Login);
|
||||||
|
|
||||||
|
function LoginPage({ mode }) {
|
||||||
|
return (
|
||||||
|
<div class="landing">
|
||||||
|
<LoginForm mode={mode} />
|
||||||
|
<div></div>
|
||||||
|
<div class="info">
|
||||||
|
<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>Based in Preston.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { h } from 'preact';
|
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
|
||||||
import style from './style.less';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Profile = ({ user }: Props) => {
|
|
||||||
const [time, setTime] = useState<number>(Date.now());
|
|
||||||
const [count, setCount] = useState<number>(0);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer = setInterval(() => setTime(Date.now()), 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={'profile'}>
|
|
||||||
<h1>Profile: {user}</h1>
|
|
||||||
<p>This is the user profile for a user named { user }.</p>
|
|
||||||
|
|
||||||
<div>Current time: {new Date(time).toLocaleString()}</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<button onClick={() => setCount((count) => count + 1)}>Click Me</button>
|
|
||||||
{' '}
|
|
||||||
Clicked {count} times.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Profile;
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||||
|
import { ExternalLink, Check } from 'react-feather';
|
||||||
|
|
||||||
|
import http from '../http';
|
||||||
|
|
||||||
|
function Result(r) {
|
||||||
|
const [added, setAdded] = useState(false)
|
||||||
|
|
||||||
|
async function onClick() {
|
||||||
|
const [error, want] = await http.postWant(r.id);
|
||||||
|
if (error) return false;
|
||||||
|
setAdded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = `${added ? 'added' : ''}`;
|
||||||
|
const btnText = added
|
||||||
|
? <Check />
|
||||||
|
: '+';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={r.id}>
|
||||||
|
<button onClick={onClick} disabled={added} class={classes} >{btnText}</button>
|
||||||
|
<div>
|
||||||
|
<h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank"><ExternalLink /></a></h3>
|
||||||
|
<h5 class='alt-text' >{r.year}</h5>
|
||||||
|
<h5 class='alt-text'>{r.country}</h5>
|
||||||
|
<h5 class='alt-text'>{r.format.join(' ')}</h5>
|
||||||
|
</div>
|
||||||
|
<img src={r.thumb} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const User = () => {
|
||||||
|
const [artist, setArtist] = useState('')
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [infoText, setInfoText] = useState('');
|
||||||
|
|
||||||
|
async function fetchResults() {
|
||||||
|
const [error, results] = await http.postWantSearch(artist, title);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
setResults([]);
|
||||||
|
return setInfoText('No results');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfoText('');
|
||||||
|
setResults(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitWantlist() {
|
||||||
|
const [error, wants] = await http.postDiscogs(username);
|
||||||
|
if (wants.length === 0) {
|
||||||
|
setResults([]);
|
||||||
|
return setInfoText('No new additions');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfoText(`Added ${wants.length} items to your list.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchRef = useRef<HTMLInputElement>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchRef.current.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscogsSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitWantlist();
|
||||||
|
}
|
||||||
|
|
||||||
|
const topEl = results.length
|
||||||
|
? <button onClick={() => window.scrollTo(0, 0)}> top </button>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const infoEl = infoText
|
||||||
|
? <article><div></div><div>{infoText}</div></article>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class='search'>
|
||||||
|
<div>
|
||||||
|
<form action="" method="get">
|
||||||
|
<label for="artist">Artist</label>
|
||||||
|
<input type="text" name="artist" ref={searchRef} placeholder="Ben Klock" id="artist" value={artist} onChange={e => setArtist((e.target as HTMLInputElement).value) } required />
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" name="title" placeholder="Subzero" id="title" value={title} onChange={e => setTitle((e.target as HTMLInputElement).value)} required />
|
||||||
|
<input type="submit" value="Search" onClick={handleSubmit} />
|
||||||
|
</form>
|
||||||
|
<hr />
|
||||||
|
<div>
|
||||||
|
<form action="" method="get">
|
||||||
|
<label for="Discogs">Discogs Username</label>
|
||||||
|
<input type="text" name="username" placeholder="Discogs Username" id="username" value={username} onChange={e => setUsername((e.target as HTMLInputElement).value) } required />
|
||||||
|
<input type="submit" value="Sync" onClick={handleDiscogsSubmit} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<p>Data provided by Discogs.</p>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
{infoEl}
|
||||||
|
{results.map(Result)}
|
||||||
|
{topEl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default User;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import createStore from 'unistore'
|
import unistore from 'unistore'
|
||||||
|
|
||||||
export default function mcratesStore() {
|
|
||||||
return createStore({ bundles: [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default function createStore() {
|
||||||
|
return unistore({
|
||||||
|
bundles: [],
|
||||||
|
offers: [],
|
||||||
|
user: null,
|
||||||
|
ws: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
+329
-66
@@ -1,90 +1,353 @@
|
|||||||
html, body {
|
// Digital Ocean reset
|
||||||
height: 100%;
|
html {
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
background: #FAFAFA;
|
|
||||||
font-family: 'Helvetica Neue', arial, sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #444;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
*, *:before, *:after {
|
||||||
height: 100%;
|
box-sizing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home {
|
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
|
||||||
padding: 56px 20px;
|
margin: 0;
|
||||||
min-height: 100%;
|
padding: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol, ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
// max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Raleway';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(https://fonts.gstatic.com/s/raleway/v28/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvaorCIPrE.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(https://fonts.gstatic.com/s/sourcecodepro/v21/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQtMlrTA.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Sans Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-stretch: normal;
|
||||||
|
src: url(https://fonts.gstatic.com/s/notosansmono/v20/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ093dVQ.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
// stuff from UX laws
|
||||||
|
html {
|
||||||
|
color:var(--base-font-color);
|
||||||
|
font-size:clamp(var(--min-font-size),calc(50% + 1vw),var(--max-font-size));
|
||||||
|
font-family:var(--base-font-family);
|
||||||
|
line-height:var(--base-line-height);
|
||||||
|
-webkit-text-size-adjust:100%;
|
||||||
|
background:var(--base-background-color);
|
||||||
|
box-sizing:border-box
|
||||||
|
}
|
||||||
|
|
||||||
|
// @charset "UTF-8";
|
||||||
|
:root {
|
||||||
|
--breakpoint-medium: 35em;
|
||||||
|
--breakpoint-large: 56em;
|
||||||
|
|
||||||
|
--color-dark-blue: #101014;
|
||||||
|
--color-mid-blue: #16171d;
|
||||||
|
--color-light-blue: #1f2128;
|
||||||
|
--color-mid-grey: #9BA0B3;
|
||||||
|
--color-off-white: #F4F1D0;
|
||||||
|
--color-accent-blue: #5E9ED6;
|
||||||
|
|
||||||
|
--color-blue-gray: #506485;
|
||||||
|
--color-green: #537a34;
|
||||||
|
|
||||||
|
--base-accent-color: var(--color-accent-blue);
|
||||||
|
--fg-color: var(--color-off-white);
|
||||||
|
--hover-link-color: var(--color-off-white);
|
||||||
|
--base-background-color: var(--color-dark-blue);
|
||||||
|
--secondary-background-color: var(--color-mid-blue);
|
||||||
|
--tertiary-background-color: var(--color-light-blue);
|
||||||
|
|
||||||
|
--small-font-size: 0.75em;
|
||||||
|
--base-font-size: 1em;
|
||||||
|
--smedium-font-size: 1.333em;
|
||||||
|
--medium-font-size: 1.777em;
|
||||||
|
--large-font-size: 2.369em;
|
||||||
|
--xlarge-font-size: 3.157em;
|
||||||
|
--base-font-family: 'Raleway', Arial, Sans-Serif;
|
||||||
|
--secondary-font-family: 'Noto Sans Mono', monospace;
|
||||||
|
--base-font-color: var(--color-off-white);
|
||||||
|
--secondary-font-color: var(--color-mid-grey);
|
||||||
|
|
||||||
|
--base-line-height: 1.4;
|
||||||
|
--header-line-height: 1.2;
|
||||||
|
|
||||||
|
--min-font-size: var(--base-font-size);
|
||||||
|
--max-font-size: var(--large-font-size);
|
||||||
|
|
||||||
|
--base-border-color: var(--secondary-font-color);
|
||||||
|
--base-border: 1px solid var(--base-border-color);
|
||||||
|
--hover-border: 1px solid var(--base-font-color);
|
||||||
|
--focus-border: 1px solid var(--base-font-color);
|
||||||
|
--base-border-radius: .2rem;
|
||||||
|
--base-focus: 2px solid var(--base-accent-color);
|
||||||
|
--base-outline-offset: 0.25em;
|
||||||
|
|
||||||
|
--base-spacing: 1rem;
|
||||||
|
--base-spacing-half: calc(var(--base-spacing)/2);
|
||||||
|
--base-spacing-double: calc(var(--base-spacing)*2);
|
||||||
|
|
||||||
|
--utility-bar-header-height: calc(var(--base-spacing) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, optgroup, select, textarea {
|
||||||
|
margin:0;
|
||||||
|
border:var(--base-border);
|
||||||
|
border-radius:var(--base-border-radius);
|
||||||
|
background:0 0;
|
||||||
|
font-family:inherit;
|
||||||
|
font-size:100%;
|
||||||
|
color:inherit;
|
||||||
|
line-height:var(--base-line-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:hover, textarea:hover {
|
||||||
|
color:var(--base-font-color);
|
||||||
|
border:var(--hover-border)
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border:var(--focus-border);
|
||||||
|
outline:0
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input {
|
||||||
|
overflow:visible
|
||||||
|
}
|
||||||
|
|
||||||
|
button, select {
|
||||||
|
text-transform:none
|
||||||
|
}
|
||||||
|
|
||||||
|
button, [type=button], [type=reset], [type=submit] {
|
||||||
|
-webkit-appearance:button;
|
||||||
|
border-color: transparent;
|
||||||
|
cursor:pointer;
|
||||||
|
|
||||||
|
font-family: var(--secondary-font-family);
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--tertiary-background-color);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok actual styles
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-top: calc(var(--base-spacing)/2);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 0 5% 0 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, [type=submit] {
|
||||||
|
padding: var(--base-spacing-half);
|
||||||
|
background: var(--secondary-background-color);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 0.5fr 2fr;
|
||||||
|
|
||||||
|
.login {
|
||||||
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
p {
|
||||||
|
margin-bottom: var(--base-spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-text {
|
||||||
|
font-family: var(--secondary-font-family);
|
||||||
|
// font-size: var(--small-font-size);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
background-color: var(--secondary-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 0.5fr 2fr;
|
||||||
|
|
||||||
|
article {
|
||||||
|
display: grid;
|
||||||
|
padding: var(--base-spacing);
|
||||||
|
margin-bottom: var(--base-spacing);
|
||||||
|
grid-template-columns: 1fr 4fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-right: var(--base-spacing);
|
||||||
|
background: var(--tertiary-background-color);
|
||||||
|
border-color: var(--secondary-background-color);
|
||||||
|
|
||||||
|
&.added {
|
||||||
|
background: var(--color-green);
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
|
||||||
|
article {
|
||||||
|
flex: 1 1 20%;
|
||||||
|
padding: var(--base-spacing);
|
||||||
|
margin: var(--base-spacing-half);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin-bottom: var(--base-spacing);
|
||||||
|
|
||||||
|
label, input {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: var(--base-spacing-half);
|
||||||
|
margin-bottom: var(--base-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: var(--secondary-font-family);
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: var(--base-spacing-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
// padding-left: var(--base-spacing);
|
||||||
|
margin-right: var(--base-spacing);
|
||||||
|
|
||||||
|
color: var(--secondary-font-color);
|
||||||
|
font-family: var(--secondary-font-family);
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--fg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr min-content;
|
||||||
|
padding-bottom: var(--base-spacing);
|
||||||
|
|
||||||
|
a {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundles {
|
||||||
|
flex-flow: row;
|
||||||
|
|
||||||
.bundle {
|
.bundle {
|
||||||
flex: 1;
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
margin-bottom: var(--base-spacing-double);
|
||||||
|
|
||||||
|
.items {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
div {
|
||||||
|
margin-right: var(--base-spacing);
|
||||||
|
margin-bottom: var(--base-spacing);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile {
|
.offers {
|
||||||
padding: 56px 20px;
|
display: grid;
|
||||||
min-height: 100%;
|
grid-template-columns: 1fr 1fr;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.offer {
|
||||||
position: fixed;
|
display: grid;
|
||||||
left: 0;
|
grid-template-areas:
|
||||||
top: 0;
|
"details img"
|
||||||
width: 100%;
|
"btn btn";
|
||||||
height: 56px;
|
|
||||||
padding: 0;
|
|
||||||
background: #673AB7;
|
|
||||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
padding: var(--base-spacing);
|
||||||
h1 {
|
margin: var(--base-spacing-half);
|
||||||
float: left;
|
background: var(--secondary-background-color);
|
||||||
margin: 0;
|
|
||||||
padding: 0 15px;
|
section {
|
||||||
font-size: 24px;
|
grid-area: details;
|
||||||
line-height: 56px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #FFF;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
img {
|
||||||
float: right;
|
grid-area: img;
|
||||||
font-size: 100%;
|
justify-self: end;
|
||||||
|
margin-left: var(--base-spacing-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a {
|
button {
|
||||||
display: inline-block;
|
grid-area: btn;
|
||||||
height: 56px;
|
|
||||||
line-height: 56px;
|
|
||||||
padding: 0 15px;
|
|
||||||
min-width: 50px;
|
|
||||||
text-align: center;
|
|
||||||
background: rgba(255,255,255,0);
|
|
||||||
text-decoration: none;
|
|
||||||
color: #FFF;
|
|
||||||
will-change: background-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:hover,
|
&.accepted {
|
||||||
nav a:active {
|
background: var(--color-accent-blue);
|
||||||
background: rgba(0,0,0,0.2);
|
cursor: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a.active {
|
|
||||||
background: rgba(0,0,0,0.4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
font-family: var(--secondary-font-family);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`;
|
||||||
|
|
||||||
|
export default function createSocket(store) {
|
||||||
|
let ws;
|
||||||
|
|
||||||
|
// -------------
|
||||||
|
// Outgoing
|
||||||
|
// -------------
|
||||||
|
function send(msg) {
|
||||||
|
if (msg[0] !== 'Ping') console.log('outgoing msg', msg);
|
||||||
|
if (ws.readyState !== 1) return false;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// let ping;
|
||||||
|
function sendPing() {
|
||||||
|
// ping = Date.now();
|
||||||
|
send(['Ping', {}]);
|
||||||
|
|
||||||
|
// setTimeout(sendPing, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSourcesAvailable() {
|
||||||
|
send(['SourcesAvailable', {}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pongTimeout;
|
||||||
|
function onPong() {
|
||||||
|
pongTimeout = setTimeout(sendPing, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------
|
||||||
|
// Incoming
|
||||||
|
// -------------
|
||||||
|
function onSourcesAvailable(clouds) {
|
||||||
|
// store.dispatch(http.LIBRARY_RECEIVE_POINTCLOUDS, clouds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------
|
||||||
|
// Setup
|
||||||
|
// -------------
|
||||||
|
// when the server sends a reply it will have one of these message types
|
||||||
|
// this object wraps the reply types to a function
|
||||||
|
const handlers = {
|
||||||
|
SourcesAvailable: onSourcesAvailable,
|
||||||
|
|
||||||
|
Pong: onPong,
|
||||||
|
Error: errHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
function errHandler(error) {
|
||||||
|
// store.dispatch(http.LIBRARY_EVENT, { level: 'error', log: error });
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(event) {
|
||||||
|
const res = event.data;
|
||||||
|
const [msgType, params] = JSON.parse(res);
|
||||||
|
if (msgType !== 'Pong') {
|
||||||
|
console.log(msgType, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for error and split into response type and data
|
||||||
|
if (!handlers[msgType]) return console.error(`${msgType} handler missing`);
|
||||||
|
return handlers[msgType](params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection opened
|
||||||
|
function onOpen() {
|
||||||
|
sendPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(event) {
|
||||||
|
console.error('WebSocket error', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose(event) {
|
||||||
|
console.error('WebSocket closed', event);
|
||||||
|
return setTimeout(connect, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws) {
|
||||||
|
clearTimeout(pongTimeout);
|
||||||
|
ws.removeEventListener('open', onOpen);
|
||||||
|
ws.removeEventListener('message', onMessage);
|
||||||
|
ws.removeEventListener('error', onError);
|
||||||
|
ws.removeEventListener('close', onClose);
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('connecting to websocket', SOCKET_URL);
|
||||||
|
ws = new WebSocket(SOCKET_URL);
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
ws.addEventListener('open', onOpen);
|
||||||
|
ws.addEventListener('message', onMessage);
|
||||||
|
ws.addEventListener('error', onError);
|
||||||
|
ws.addEventListener('close', onClose);
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
|
||||||
|
sendSourcesAvailable,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
version: "3.3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: postgres:latest
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: 'mcrates'
|
||||||
|
POSTGRES_DB: 'mcrates'
|
||||||
|
POSTGRES_PASSWORD: 'digdeep'
|
||||||
|
|
||||||
|
# allow prisma client to push changes from outside docker etc
|
||||||
|
# ufw blocks this from being publicly accessible
|
||||||
|
ports:
|
||||||
|
- '45432:5432'
|
||||||
|
volumes:
|
||||||
|
- "./db:/var/lib/postgresql/data"
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: node:16
|
||||||
|
working_dir: /opt/mcrates/api
|
||||||
|
entrypoint: npm start
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
|
||||||
|
volumes:
|
||||||
|
- "./api:/opt/mcrates/api"
|
||||||
|
|
||||||
|
client:
|
||||||
|
image: node:16
|
||||||
|
working_dir: /opt/mcrates/client
|
||||||
|
entrypoint: npm start
|
||||||
|
volumes:
|
||||||
|
- "./client:/opt/mcrates/client"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
volumes:
|
||||||
|
- './nginx/matescrates.au.nginx.conf:/etc/nginx/conf.d/default.conf'
|
||||||
|
- '/etc/letsencrypt:/etc/letsencrypt'
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: "3.3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: postgres:latest
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: 'mcrates'
|
||||||
|
POSTGRES_DB: 'test'
|
||||||
|
POSTGRES_PASSWORD: 'digdeep'
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: node:16
|
||||||
|
working_dir: /opt/mcrates/api
|
||||||
|
entrypoint: npm run test:watch
|
||||||
|
depends_on:
|
||||||
|
- pg
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://mcrates:digdeep@pg:5432/test?schema=public"
|
||||||
|
volumes:
|
||||||
|
- "./api:/opt/mcrates/api"
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
version: "3.3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: postgres:latest
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: 'mcrates'
|
||||||
|
POSTGRES_DB: 'mcrates'
|
||||||
|
POSTGRES_PASSWORD: 'digdeep'
|
||||||
|
ports:
|
||||||
|
- '45432:5432'
|
||||||
|
volumes:
|
||||||
|
- "./db:/var/lib/postgresql/data"
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: node:16
|
||||||
|
working_dir: /opt/mcrates/api
|
||||||
|
depends_on:
|
||||||
|
- 'pg'
|
||||||
|
entrypoint: npm start
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
|
||||||
|
volumes:
|
||||||
|
- "./api:/opt/mcrates/api"
|
||||||
|
|
||||||
|
client:
|
||||||
|
image: node:16
|
||||||
|
working_dir: /opt/mcrates/client
|
||||||
|
entrypoint: npm start
|
||||||
|
ports:
|
||||||
|
- '8080:8080' # fwd the hmr websocket
|
||||||
|
volumes:
|
||||||
|
- "./client:/opt/mcrates/client"
|
||||||
|
- "./api:/opt/mcrates/api"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
depends_on:
|
||||||
|
- 'api'
|
||||||
|
volumes:
|
||||||
|
- './nginx/localhost.nginx.conf:/etc/nginx/conf.d/default.conf'
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
upstream mcrates_api {
|
||||||
|
server api:41337;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream mcrates_client {
|
||||||
|
server client:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
location / {
|
||||||
|
proxy_pass http://mcrates_client;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ws {
|
||||||
|
proxy_pass http://mcrates_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
# trailing slash means api will receive url WITHOUT /api
|
||||||
|
proxy_pass http://mcrates_api/;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
upstream mcrates_api {
|
||||||
|
server api:41337;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream mcrates_client {
|
||||||
|
server client:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
# upstream mcrates_ws {
|
||||||
|
# server 127.0.0.1:40055;
|
||||||
|
# }
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
# DEV
|
||||||
|
server {
|
||||||
|
server_name matescrates.au;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://mcrates_client;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# location /api/ws {
|
||||||
|
# proxy_pass http://mcrates_ws;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection $connection_upgrade;
|
||||||
|
# proxy_read_timeout 600s;
|
||||||
|
# }
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
# trailing slash means api will receive url WITHOUT /api
|
||||||
|
proxy_pass http://mcrates_api/;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/matescrates.au/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/matescrates.au/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
}
|
||||||
|
|
||||||
|
# http -> https
|
||||||
|
server {
|
||||||
|
server_name matescrates.au;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
version: "3.3"
|
|
||||||
|
|
||||||
services:
|
|
||||||
pg:
|
|
||||||
image: postgres:latest
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: 'mcrates'
|
|
||||||
POSTGRES_DB: 'mcrates'
|
|
||||||
POSTGRES_PASSWORD: 'digdeep'
|
|
||||||
ports:
|
|
||||||
- '45432:5432'
|
|
||||||
volumes:
|
|
||||||
- "./db:/var/lib/postgresql/data"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import fs from 'fs/promises';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import Bunyan from 'bunyan';
|
|
||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
import Promise from "bluebird";
|
|
||||||
|
|
||||||
import discogsService from './src/discogs';
|
|
||||||
import pgService from './src/pg';
|
|
||||||
import { logAxiosError } from './src/utils';
|
|
||||||
import createLog from './src/log';
|
|
||||||
import createApi from './src/api';
|
|
||||||
|
|
||||||
async function start(log) {
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const pg = pgService(log, prisma);
|
|
||||||
const discogs = discogsService(log, pg);
|
|
||||||
const api = createApi(log, pg);
|
|
||||||
|
|
||||||
const usernames = [
|
|
||||||
'vanderbabe',
|
|
||||||
'margh',
|
|
||||||
'jmcdermid1',
|
|
||||||
];
|
|
||||||
|
|
||||||
async function populateUserListings(user) {
|
|
||||||
log.info(user);
|
|
||||||
const wants = await discogs.wantlist(user);
|
|
||||||
|
|
||||||
// const latest = await discogs.wantlistLatest(user);
|
|
||||||
// if (wants.length !== latest.length)
|
|
||||||
log.info({ username: user.discogs, wantCount: wants.length });
|
|
||||||
|
|
||||||
return Promise.mapSeries(wants, async want => {
|
|
||||||
const listings = await discogs.listings(want);
|
|
||||||
const populated = listings.filter(l => l);
|
|
||||||
log.info({ id: want.id, release: want.release, populated: populated.length, total: listings.length });
|
|
||||||
return populated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// await Promise.mapSeries(usernames, async u => {
|
|
||||||
// const user = await discogs.user(u)
|
|
||||||
// return populateUserListings(user);
|
|
||||||
// });
|
|
||||||
|
|
||||||
await api.listen();
|
|
||||||
|
|
||||||
// return getBundles();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const log = createLog();
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
log.debug('SIGTERM received, exiting');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await start(log)
|
|
||||||
// log.info('done syncing', { totalListings: r.flat().length });
|
|
||||||
log.info(r);
|
|
||||||
setTimeout(run, 60 * 1000);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message === 'rate limited') {
|
|
||||||
log.info('rate limited, trying again in 60s');
|
|
||||||
return setTimeout(run, 60 * 1000);
|
|
||||||
}
|
|
||||||
log.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
Generated
-2963
File diff suppressed because it is too large.
Load diff
@@ -1,84 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import http from 'http';
|
|
||||||
import cors from 'cors';
|
|
||||||
import { groupBy } from 'lodash';
|
|
||||||
|
|
||||||
import type Log from 'bunyan';
|
|
||||||
|
|
||||||
import { Pg } from './pg';
|
|
||||||
import type { Listing, User, Prisma } from '@prisma/client';
|
|
||||||
|
|
||||||
export default function createApi(log: Log, pg: Pg) {
|
|
||||||
const PORT = 41337;
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const server = http.createServer(app);
|
|
||||||
|
|
||||||
interface ListingData extends Listing {
|
|
||||||
data: any,
|
|
||||||
want: { user: User },
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getBundles() {
|
|
||||||
const bundles = await pg.bundleCandidates();
|
|
||||||
|
|
||||||
return bundles.map((b: ListingData[]) => {
|
|
||||||
const total = b.reduce((acc, l) => acc + l.data.original_price.value, 0);
|
|
||||||
|
|
||||||
const items = b.map(l => ({
|
|
||||||
id: l.data.id,
|
|
||||||
description: l.data.release.description,
|
|
||||||
price: l.data.original_price.value,
|
|
||||||
currency: l.data.original_price.curr_abbr,
|
|
||||||
condition: l.data.condition,
|
|
||||||
uri: l.data.uri,
|
|
||||||
user: l.want.user.discogs,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const shipping = b[0]?.data.seller.shipping;
|
|
||||||
|
|
||||||
console.log(b.map(l => l.data));
|
|
||||||
|
|
||||||
const bundle = {
|
|
||||||
username: b[0]?.data.seller.username,
|
|
||||||
seller: b[0]?.data.seller.id,
|
|
||||||
shipsFrom: b[0]?.data.ships_from,
|
|
||||||
total,
|
|
||||||
currency: b[0]?.data.original_price.curr_abbr,
|
|
||||||
shipping: '...',
|
|
||||||
items,
|
|
||||||
mentionsAustralia: shipping.toLowerCase().includes('australia'),
|
|
||||||
mentionsWorldwide: shipping.toLowerCase().includes('world'),
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(bundle);
|
|
||||||
return bundle;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function listen() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
server.on('error', err => {
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
log.info('listening', { PORT });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
app.get('/bundles', async (req, res, next) => {
|
|
||||||
const bundles = await getBundles();
|
|
||||||
// log.info(bundles);
|
|
||||||
res.json(bundles);
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
app,
|
|
||||||
listen,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import type Log from 'bunyan';
|
|
||||||
import type { PrismaClient, Want, User, Listing, Prisma } from '@prisma/client';
|
|
||||||
import * as Bluebird from 'bluebird';
|
|
||||||
|
|
||||||
export interface Pg {
|
|
||||||
userFindId: (id: UUID) => Promise<User>,
|
|
||||||
userFindName: (name: DiscogsName) => Promise<User>,
|
|
||||||
userUpsert: (user: User) => Promise<User>,
|
|
||||||
userWants: (user: User) => Promise<Want[]>,
|
|
||||||
|
|
||||||
wantUpsert: (want: Want, user: User) => Promise<Want>,
|
|
||||||
listingUpsert: (listing: Listing, want: Want) => Promise<Listing>,
|
|
||||||
listingFind: (id: DiscogsListing) => Promise<Listing>,
|
|
||||||
listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise<number>,
|
|
||||||
|
|
||||||
bundleCandidates: () => Promise<Listing[][]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
|
||||||
async function userFindId(id) {
|
|
||||||
return prisma.user.findUnique({ where: { id }});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function userFindName(name: DiscogsName) {
|
|
||||||
return prisma.user.findUnique({ where: { discogs: name }});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function userWants(user: User) {
|
|
||||||
return prisma.want.findMany({ where: { userId: user.id }});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function userUpsert(user) {
|
|
||||||
const data = {
|
|
||||||
discogs: user.username,
|
|
||||||
data: user as Prisma.JsonObject,
|
|
||||||
};
|
|
||||||
|
|
||||||
return prisma.user.upsert({
|
|
||||||
where: {
|
|
||||||
discogs: user.username,
|
|
||||||
},
|
|
||||||
update: data,
|
|
||||||
create: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wantUpsert(dcWant, user) {
|
|
||||||
const values: { userId: UUID, release: DiscogsListing, data: any } = {
|
|
||||||
data: dcWant,
|
|
||||||
release: dcWant.id,
|
|
||||||
userId: user.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return prisma.want.upsert({
|
|
||||||
// get these from the generated types
|
|
||||||
// ./node_modules/.prisma/client/index.d.ts
|
|
||||||
where: { userId_release: {
|
|
||||||
userId: values.userId,
|
|
||||||
release: values.release || null,
|
|
||||||
}},
|
|
||||||
update: values,
|
|
||||||
create: values,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listingRmUnlisted(want: Want, ids: DiscogsListing[]) {
|
|
||||||
const { count } = await prisma.listing.deleteMany({
|
|
||||||
where: {
|
|
||||||
wantId: want.id,
|
|
||||||
id: {
|
|
||||||
notIn: ids,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listingUpsert(listing, want) {
|
|
||||||
const values = {
|
|
||||||
id: listing.id,
|
|
||||||
wantId: want.id,
|
|
||||||
seller: listing.seller.id,
|
|
||||||
data: listing,
|
|
||||||
};
|
|
||||||
|
|
||||||
return prisma.listing.upsert({
|
|
||||||
where: {
|
|
||||||
id: listing.id
|
|
||||||
},
|
|
||||||
update: values,
|
|
||||||
create: values,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listingFind(id: DiscogsListing) {
|
|
||||||
return prisma.listing.findUnique({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
want: {
|
|
||||||
include: { user: true },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bundleCandidates(): Promise<Listing[][]> {
|
|
||||||
const candidates = await prisma.$queryRaw`
|
|
||||||
select
|
|
||||||
seller, count(distinct("wantId")), array_agg(id) as listings
|
|
||||||
from
|
|
||||||
"Listing"
|
|
||||||
group by
|
|
||||||
seller
|
|
||||||
order by
|
|
||||||
count desc
|
|
||||||
limit 5;
|
|
||||||
` as BundleCandidate[];
|
|
||||||
|
|
||||||
return Promise.all(candidates.map(async c => prisma.listing.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: c.listings }
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
want: {
|
|
||||||
include: { user: true },
|
|
||||||
}
|
|
||||||
},
|
|
||||||
distinct: ['wantId'],
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userFindId,
|
|
||||||
userFindName,
|
|
||||||
userUpsert,
|
|
||||||
userWants,
|
|
||||||
|
|
||||||
wantUpsert,
|
|
||||||
listingUpsert,
|
|
||||||
listingFind,
|
|
||||||
listingRmUnlisted,
|
|
||||||
|
|
||||||
bundleCandidates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user