offers wip
This commit is contained in:
1 parent
453c2da045
commit
deb9eb3fc2
16 files changed
+247
-59
No files matched your search
@@ -5,6 +5,16 @@ 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);
|
||||
|
||||
@@ -16,6 +16,8 @@ model Listing {
|
||||
wantId String @db.Uuid
|
||||
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
offers Offer[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -32,6 +34,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
wants Want[]
|
||||
offers Offer[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -48,9 +51,36 @@ model Want {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
listings Listing[]
|
||||
offers Offer[]
|
||||
|
||||
@@unique([userId, release])
|
||||
@@index([release])
|
||||
|
||||
@@map("wants")
|
||||
}
|
||||
|
||||
model Offer {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
|
||||
status String
|
||||
|
||||
item Decimal @db.Money
|
||||
shipping Decimal @db.Money
|
||||
total Decimal @db.Money
|
||||
comparison Decimal @db.Money
|
||||
|
||||
sellerId Int
|
||||
|
||||
listingId Int
|
||||
listing Listing @relation(fields: [listingId], references: [id])
|
||||
|
||||
userId String @db.Uuid
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
wantId String @db.Uuid
|
||||
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
// only one offer per want for a user
|
||||
@@unique([userId, wantId])
|
||||
@@map("offers")
|
||||
}
|
||||
+18
-7
@@ -5,10 +5,13 @@ import createServices from './services';
|
||||
import createLog from './log';
|
||||
|
||||
const log = createLog();
|
||||
log.level('error');
|
||||
log.level('debug');
|
||||
const services = createServices(log);
|
||||
|
||||
const HOST = 'http://localhost:41337';
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:41337',
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
before(function setup() {
|
||||
services.api.listen();
|
||||
@@ -19,8 +22,9 @@ after(function teardown(done) {
|
||||
});
|
||||
|
||||
describe('api', function apiTests() {
|
||||
describe.skip('auth', function authTests() {
|
||||
it('listens', async function listenTest() {
|
||||
const res = await axios.get(`${HOST}/health`);
|
||||
const res = await api.get('/health');
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
@@ -29,7 +33,7 @@ describe('api', function apiTests() {
|
||||
email: `test+${Date.now()}@user.com`,
|
||||
password: 'woaaah theres no limit',
|
||||
};
|
||||
const { data } = await axios.post(`${HOST}/auth/register`, body);
|
||||
const { data } = await api.post('/auth/register', body);
|
||||
assert.equal(data.email, body.email);
|
||||
});
|
||||
|
||||
@@ -38,10 +42,17 @@ describe('api', function apiTests() {
|
||||
email: `test+${Date.now()}@user.com`,
|
||||
password: 'woaaah theres no limit',
|
||||
};
|
||||
await axios.post(`${HOST}/auth/register`, body);
|
||||
const res = await axios.post(`${HOST}/auth/login`, body);
|
||||
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 { seller } = bundles[0];
|
||||
const { data } = await api.post('/offers', { seller, shipping: 50.0, baseShipping: 15.0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
+14
-14
@@ -16,7 +16,7 @@ import { Pg } from './pg';
|
||||
import routerAuth from './routers/router.auth';
|
||||
import { Events } from './events';
|
||||
|
||||
export default function createApi(log: Log, pg: Pg, discogs, events) {
|
||||
export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
|
||||
const PORT = 41337;
|
||||
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
|
||||
|
||||
@@ -46,13 +46,14 @@ export default function createApi(log: Log, pg: Pg, discogs, events) {
|
||||
const bundles = await pg.bundleCandidates();
|
||||
|
||||
return bundles.map((b: ListingData[]) => {
|
||||
const total = b.reduce((acc, l) => acc + l.data.original_price.value, 0);
|
||||
|
||||
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,
|
||||
price: l.data.original_price.value,
|
||||
currency: l.data.original_price.curr_abbr,
|
||||
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,
|
||||
@@ -65,7 +66,7 @@ export default function createApi(log: Log, pg: Pg, discogs, events) {
|
||||
seller: b[0]?.data.seller.id,
|
||||
shipsFrom: b[0]?.data.ships_from,
|
||||
total,
|
||||
currency: b[0]?.data.original_price.curr_abbr,
|
||||
currency: b[0]?.data.price.currency,
|
||||
shipping: '...',
|
||||
items,
|
||||
mentionsAustralia: shipping.toLowerCase().includes('australia'),
|
||||
@@ -108,17 +109,16 @@ export default function createApi(log: Log, pg: Pg, discogs, events) {
|
||||
ctx.response.body = bundles;
|
||||
})
|
||||
|
||||
router.post('/offers', async (ctx, next) => {
|
||||
const { seller, shipping, baseShipping } = ctx.request.body;
|
||||
ctx.response.body = await offers.generate(seller, shipping, baseShipping);
|
||||
})
|
||||
|
||||
router.get('/user/offers', async function userOffersGet(ctx, next) {
|
||||
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
|
||||
const user = ctx.state.user;
|
||||
const bundles = (await getBundles())
|
||||
.map(b => {
|
||||
b.items = b.items.filter(i => i.user.id === user.id);
|
||||
return b;
|
||||
})
|
||||
.filter(b => b.items.length);
|
||||
|
||||
ctx.response.body = bundles;
|
||||
const offers = await pg.userOffers(user);
|
||||
ctx.response.body = offers;
|
||||
});
|
||||
|
||||
router.post('/want', async function wantPost(ctx, next) {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { Pg } from './pg';
|
||||
// vanderbabe
|
||||
// jmcdermid1
|
||||
|
||||
export default function discogs(log: Log, pg: Pg) {
|
||||
export default function createDiscogs(log: Log, pg: Pg) {
|
||||
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
|
||||
const apiBase = 'https://api.discogs.com'
|
||||
const discogsBase = 'https://www.discogs.com'
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export enum Events {
|
||||
StartupSync = 'StartupSync',
|
||||
}
|
||||
|
||||
export function eventService(log: Log, pg: Pg, discogs) {
|
||||
export default function createEvents(log: Log, pg: Pg, discogs) {
|
||||
const events = new EventEmitter({ captureRejections: true });
|
||||
|
||||
events.on('error', function eventError(e) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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 { logAxiosError } from './utils';
|
||||
import { Pg } from './pg';
|
||||
|
||||
export enum Status {
|
||||
Pending = 'pending',
|
||||
Accepted = 'accepted',
|
||||
Ordered = 'ordered',
|
||||
Delivered = 'delivered',
|
||||
}
|
||||
|
||||
export default function createOffers(log: Log, pg: Pg) {
|
||||
async function generate(seller: number, bulkShipping: number, baseShipping: number): Offer[] {
|
||||
log.info('generateOffers', { seller, bulkShipping, baseShipping });
|
||||
const listings = await pg.listingFindSeller(seller);
|
||||
|
||||
log.info('creating offers for listings', { listings })
|
||||
|
||||
const shipping = bulkShipping / listings.length;
|
||||
const offers = listings.map(l => ({
|
||||
userId: l.want.user.id,
|
||||
listingId: l.id,
|
||||
wantId: l.want.id,
|
||||
|
||||
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);
|
||||
console.log(inserts);
|
||||
return inserts;
|
||||
}
|
||||
|
||||
return {
|
||||
generate,
|
||||
};
|
||||
}
|
||||
+49
-4
@@ -1,6 +1,6 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import type Log from 'bunyan';
|
||||
import type { PrismaClient, Want, User, Listing, Prisma } from '@prisma/client';
|
||||
import type { PrismaClient, Want, User, Listing, Offer, Prisma } from '@prisma/client';
|
||||
import * as Bluebird from 'bluebird';
|
||||
|
||||
export interface UserCreateParams {
|
||||
@@ -13,15 +13,20 @@ export interface Pg {
|
||||
userFindEmail: (email: Email) => Promise<User>,
|
||||
userCreate: (params: UserCreateParams) => Promise<User>,
|
||||
userWants: (user: User) => Promise<Want[]>,
|
||||
userOffers: (user: User) => Promise<Offer[]>,
|
||||
|
||||
listingUpsert: (listing: Listing, want: Want) => Promise<Listing>,
|
||||
listingFind: (id: DiscogsListing) => Promise<Listing>,
|
||||
listingFindSeller: (id: DiscogsSeller) => Promise<Listing[]>,
|
||||
listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise<number>,
|
||||
|
||||
wantCreate: (discogsWant: any, user: User) => Promise<Want>,
|
||||
wantList: (user: User) => Promise<Want[]>,
|
||||
wantsToSync: () => Promise<Want[]>,
|
||||
listingUpsert: (listing: Listing, want: Want) => Promise<Listing>,
|
||||
listingFind: (id: DiscogsListing) => Promise<Listing>,
|
||||
listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise<number>,
|
||||
|
||||
bundleCandidates: () => Promise<Listing[][]>,
|
||||
|
||||
offersCreate: (offers: Offer[]) => Promise<Offer[]>,
|
||||
}
|
||||
|
||||
export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
||||
@@ -37,6 +42,15 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
||||
return prisma.want.findMany({ where: { userId: user.id }});
|
||||
}
|
||||
|
||||
async function userOffers(user: User) {
|
||||
return prisma.offer.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
want: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function userCreate(params: UserCreateParams) {
|
||||
const hashRounds = 10;
|
||||
const password = await bcrypt.hash(params.password, hashRounds);
|
||||
@@ -131,6 +145,19 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
||||
});
|
||||
}
|
||||
|
||||
async function listingFindSeller(seller: DiscogsSeller): Promise<Listing[]> {
|
||||
return prisma.listing.findMany({
|
||||
where: {
|
||||
seller,
|
||||
},
|
||||
include: {
|
||||
want: {
|
||||
include: { user: true },
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function bundleCandidates(): Promise<Listing[][]> {
|
||||
const candidates = await prisma.$queryRaw`
|
||||
select
|
||||
@@ -157,11 +184,27 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
||||
})));
|
||||
}
|
||||
|
||||
async function offersCreate(offers: Offer[]) {
|
||||
return await prisma.$transaction(
|
||||
offers.map(offer => prisma.offer.upsert({
|
||||
where: {
|
||||
userId_wantId: {
|
||||
userId: offer.userId,
|
||||
wantId: offer.wantId,
|
||||
},
|
||||
},
|
||||
create: offer,
|
||||
update: offer
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
userFindId,
|
||||
userFindEmail,
|
||||
userCreate,
|
||||
userWants,
|
||||
userOffers,
|
||||
|
||||
wantCreate,
|
||||
wantList,
|
||||
@@ -169,7 +212,9 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
|
||||
listingUpsert,
|
||||
listingFind,
|
||||
listingRmUnlisted,
|
||||
listingFindSeller,
|
||||
|
||||
bundleCandidates,
|
||||
offersCreate,
|
||||
};
|
||||
}
|
||||
+10
-8
@@ -1,17 +1,19 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
import discogsService from './discogs';
|
||||
import pgService from './pg';
|
||||
import createDiscogs from './discogs';
|
||||
import createOffers from './offers';
|
||||
import createLog from './log';
|
||||
import createApi from './api';
|
||||
import { eventService } from './events';
|
||||
import createEvents from './events';
|
||||
import createPg from './pg';
|
||||
|
||||
export default function createServices(log) {
|
||||
export default function creates(log) {
|
||||
const prisma = new PrismaClient();
|
||||
const pg = pgService(log, prisma);
|
||||
const discogs = discogsService(log, pg);
|
||||
const events = eventService(log, pg, discogs);
|
||||
const api = createApi(log, pg, discogs, events);
|
||||
const pg = createPg(log, prisma);
|
||||
const discogs = createDiscogs(log, pg);
|
||||
const events = createEvents(log, pg, discogs);
|
||||
const offers = createOffers(log, pg);
|
||||
const api = createApi(log, pg, discogs, offers, events);
|
||||
|
||||
return {
|
||||
api,
|
||||
|
||||
@@ -1 +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.
|
||||
+1
-1
@@ -20,7 +20,7 @@ function App() {
|
||||
const [error, user] = await actions.getLogin();
|
||||
if (error) return route('/login');
|
||||
setUser(user);
|
||||
route('/search');
|
||||
route('/bundles');
|
||||
}
|
||||
|
||||
getLogin();
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Bundle(bundle) {
|
||||
|
||||
|
||||
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" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="baseShipping">Base Shipping Cost</label>
|
||||
<input type="number" name="baseShipping" id="baseShipping" required />
|
||||
</div>
|
||||
<div class="item">
|
||||
<input type="submit" value="Generate Offers" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class='items'>
|
||||
{bundle.items.map(i => (
|
||||
<div key={i.id}>
|
||||
<a href={i.uri} target="_blank" >
|
||||
<h4>{i.description}</h4>
|
||||
</a>
|
||||
<h5>{i.user.name}</h5>
|
||||
<h5>{i.price} {i.currency}</h5>
|
||||
<hr />
|
||||
<h5>{i.price.toFixed(2)} {i.currency}</h5>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import { h } from 'preact';
|
||||
import { ExternalLink } from 'react-feather';
|
||||
|
||||
export default function Bundle(bundle) {
|
||||
export default function Offer(offer) {
|
||||
const { want: { data: details } } = offer;
|
||||
return (
|
||||
<div class="bundle">
|
||||
<h3>{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}</h3>
|
||||
{bundle.items.map(i => (
|
||||
<div key={i.id}>
|
||||
<a href={i.uri} target="_blank" >
|
||||
<h4>{i.description}</h4>
|
||||
</a>
|
||||
<h5>{i.user.name}</h5>
|
||||
<h5>{i.price} {i.currency}</h5>
|
||||
<hr />
|
||||
</div>
|
||||
))}
|
||||
<div class="offer">
|
||||
<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>
|
||||
<img src={details.thumb} />
|
||||
<h5>${offer.total}</h5>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ function Bundles() {
|
||||
|
||||
if (!bundles.length) {
|
||||
return (
|
||||
<div class={'home'}>
|
||||
<div class={'bundles'}>
|
||||
loading...
|
||||
</div>
|
||||
);
|
||||
@@ -26,7 +26,7 @@ function Bundles() {
|
||||
|
||||
|
||||
return (
|
||||
<div class={'home'}>
|
||||
<div class={'bundles'}>
|
||||
{bundles.map(Bundle)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ function Home() {
|
||||
async function getOffers() {
|
||||
const [error, offers] = await actions.getOffers();
|
||||
if (error) return console.error(error);
|
||||
console.log(offers);
|
||||
setOffers(offers);
|
||||
}
|
||||
getOffers();
|
||||
@@ -18,7 +19,7 @@ function Home() {
|
||||
|
||||
if (!offers.length) {
|
||||
return (
|
||||
<div class={'home'}>
|
||||
<div class={'offers'}>
|
||||
loading...
|
||||
</div>
|
||||
);
|
||||
@@ -26,7 +27,7 @@ function Home() {
|
||||
|
||||
|
||||
return (
|
||||
<div class={'home'}>
|
||||
<div class={'offers'}>
|
||||
{offers.map(Offer)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -283,12 +283,30 @@ header {
|
||||
padding-bottom: var(--base-spacing);
|
||||
}
|
||||
|
||||
.home {
|
||||
display: flex;
|
||||
.bundles {
|
||||
flex-flow: row;
|
||||
|
||||
.bundle {
|
||||
flex: 1;
|
||||
padding-right: 1em;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
margin-bottom: var(--base-spacing);
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.offers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user