offers wip

This commit is contained in:
ntr committed 2022-08-23 00:02:50 +10:00
1 parent 453c2da045
commit deb9eb3fc2
16 files changed
+247 -59

No files matched your search

+10
View File
@@ -5,6 +5,16 @@ import { Events } from './src/events';
async function start() { async function start() {
const log = createLog(); 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', () => { process.on('SIGTERM', () => {
log.debug('SIGTERM received, exiting'); log.debug('SIGTERM received, exiting');
process.exit(0); process.exit(0);
+30
View File
@@ -16,6 +16,8 @@ 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
@@ -32,6 +34,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
wants Want[] wants Want[]
offers Offer[]
@@map("users") @@map("users")
} }
@@ -48,9 +51,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") @@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
View File
@@ -5,10 +5,13 @@ import createServices from './services';
import createLog from './log'; import createLog from './log';
const log = createLog(); const log = createLog();
log.level('error'); log.level('debug');
const services = createServices(log); const services = createServices(log);
const HOST = 'http://localhost:41337'; const api = axios.create({
baseURL: 'http://localhost:41337',
withCredentials: true
});
before(function setup() { before(function setup() {
services.api.listen(); services.api.listen();
@@ -19,8 +22,9 @@ after(function teardown(done) {
}); });
describe('api', function apiTests() { describe('api', function apiTests() {
describe.skip('auth', function authTests() {
it('listens', async function listenTest() { it('listens', async function listenTest() {
const res = await axios.get(`${HOST}/health`); const res = await api.get('/health');
assert.equal(res.status, 200); assert.equal(res.status, 200);
}); });
@@ -29,7 +33,7 @@ describe('api', function apiTests() {
email: `test+${Date.now()}@user.com`, email: `test+${Date.now()}@user.com`,
password: 'woaaah theres no limit', 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); assert.equal(data.email, body.email);
}); });
@@ -38,10 +42,17 @@ describe('api', function apiTests() {
email: `test+${Date.now()}@user.com`, email: `test+${Date.now()}@user.com`,
password: 'woaaah theres no limit', password: 'woaaah theres no limit',
}; };
await axios.post(`${HOST}/auth/register`, body); await api.post('/auth/register', body);
const res = await axios.post(`${HOST}/auth/login`, body); const res = await api.post('/auth/login', body);
assert(res.headers['set-cookie']); 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
View File
@@ -16,7 +16,7 @@ import { Pg } from './pg';
import routerAuth from './routers/router.auth'; import routerAuth from './routers/router.auth';
import { Events } from './events'; 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 PORT = 41337;
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth'; const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
@@ -46,13 +46,14 @@ export default function createApi(log: Log, pg: Pg, discogs, events) {
const bundles = await pg.bundleCandidates(); const bundles = await pg.bundleCandidates();
return bundles.map((b: ListingData[]) => { 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 => ({ const items = b.map(l => ({
id: l.data.id, id: l.data.id,
description: l.data.release.description, description: l.data.release.description,
price: l.data.original_price.value, originalPrice: l.data.original_price.value,
currency: l.data.original_price.curr_abbr, originalCurrency: l.data.original_price.curr_abbr,
price: l.data.price.value,
currency: l.data.price.currency,
condition: l.data.condition, condition: l.data.condition,
uri: l.data.uri, uri: l.data.uri,
user: l.want.user, user: l.want.user,
@@ -65,7 +66,7 @@ export default function createApi(log: Log, pg: Pg, discogs, events) {
seller: b[0]?.data.seller.id, seller: b[0]?.data.seller.id,
shipsFrom: b[0]?.data.ships_from, shipsFrom: b[0]?.data.ships_from,
total, total,
currency: b[0]?.data.original_price.curr_abbr, currency: b[0]?.data.price.currency,
shipping: '...', shipping: '...',
items, items,
mentionsAustralia: shipping.toLowerCase().includes('australia'), mentionsAustralia: shipping.toLowerCase().includes('australia'),
@@ -108,17 +109,16 @@ export default function createApi(log: Log, pg: Pg, discogs, events) {
ctx.response.body = bundles; 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) { router.get('/user/offers', async function userOffersGet(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required'); ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user; const user = ctx.state.user;
const bundles = (await getBundles()) const offers = await pg.userOffers(user);
.map(b => { ctx.response.body = offers;
b.items = b.items.filter(i => i.user.id === user.id);
return b;
})
.filter(b => b.items.length);
ctx.response.body = bundles;
}); });
router.post('/want', async function wantPost(ctx, next) { router.post('/want', async function wantPost(ctx, next) {
+1 -1
View File
@@ -14,7 +14,7 @@ import { Pg } from './pg';
// vanderbabe // vanderbabe
// jmcdermid1 // jmcdermid1
export default function discogs(log: Log, pg: Pg) { export default function createDiscogs(log: Log, pg: Pg) {
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD'; 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'
+1 -1
View File
@@ -11,7 +11,7 @@ export enum Events {
StartupSync = 'StartupSync', 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 }); const events = new EventEmitter({ captureRejections: true });
events.on('error', function eventError(e) { events.on('error', function eventError(e) {
+52
View File
@@ -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
View File
@@ -1,6 +1,6 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import type Log from 'bunyan'; 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'; import * as Bluebird from 'bluebird';
export interface UserCreateParams { export interface UserCreateParams {
@@ -13,15 +13,20 @@ export interface Pg {
userFindEmail: (email: Email) => Promise<User>, userFindEmail: (email: Email) => Promise<User>,
userCreate: (params: UserCreateParams) => Promise<User>, userCreate: (params: UserCreateParams) => Promise<User>,
userWants: (user: User) => Promise<Want[]>, 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>, wantCreate: (discogsWant: any, user: User) => Promise<Want>,
wantList: (user: User) => Promise<Want[]>, wantList: (user: User) => Promise<Want[]>,
wantsToSync: () => 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[][]>, bundleCandidates: () => Promise<Listing[][]>,
offersCreate: (offers: Offer[]) => Promise<Offer[]>,
} }
export default function createPg(log: Log, prisma: PrismaClient): Pg { 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 }}); 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) { async function userCreate(params: UserCreateParams) {
const hashRounds = 10; const hashRounds = 10;
const password = await bcrypt.hash(params.password, hashRounds); 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[][]> { async function bundleCandidates(): Promise<Listing[][]> {
const candidates = await prisma.$queryRaw` const candidates = await prisma.$queryRaw`
select 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 { return {
userFindId, userFindId,
userFindEmail, userFindEmail,
userCreate, userCreate,
userWants, userWants,
userOffers,
wantCreate, wantCreate,
wantList, wantList,
@@ -169,7 +212,9 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
listingUpsert, listingUpsert,
listingFind, listingFind,
listingRmUnlisted, listingRmUnlisted,
listingFindSeller,
bundleCandidates, bundleCandidates,
offersCreate,
}; };
} }
+10 -8
View File
@@ -1,17 +1,19 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import discogsService from './discogs'; import createDiscogs from './discogs';
import pgService from './pg'; import createOffers from './offers';
import createLog from './log'; import createLog from './log';
import createApi from './api'; 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 prisma = new PrismaClient();
const pg = pgService(log, prisma); const pg = createPg(log, prisma);
const discogs = discogsService(log, pg); const discogs = createDiscogs(log, pg);
const events = eventService(log, pg, discogs); const events = createEvents(log, pg, discogs);
const api = createApi(log, pg, discogs, events); const offers = createOffers(log, pg);
const api = createApi(log, pg, discogs, offers, events);
return { return {
api, api,
+1
View File
@@ -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. 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
View File
@@ -20,7 +20,7 @@ function App() {
const [error, user] = await actions.getLogin(); const [error, user] = await actions.getLogin();
if (error) return route('/login'); if (error) return route('/login');
setUser(user); setUser(user);
route('/search'); route('/bundles');
} }
getLogin(); getLogin();
+24 -2
View File
@@ -1,21 +1,43 @@
import { h } from 'preact'; import { h } from 'preact';
export default function Bundle(bundle) { export default function Bundle(bundle) {
return ( return (
<div class="bundle"> <div class="bundle">
<section>
<h2>{bundle.username} - {bundle.seller}</h2> <h2>{bundle.username} - {bundle.seller}</h2>
<h3>{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}</h3> <h3>{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}</h3>
<h4>Ships: {bundle.mentionsAustralia && 'AU'} {bundle.mentionsWorldwide && 'WORLD'}</h4> <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 => ( {bundle.items.map(i => (
<div key={i.id}> <div key={i.id}>
<a href={i.uri} target="_blank" > <a href={i.uri} target="_blank" >
<h4>{i.description}</h4> <h4>{i.description}</h4>
</a> </a>
<h5>{i.user.name}</h5> <h5>{i.user.name}</h5>
<h5>{i.price} {i.currency}</h5> <h5>{i.price.toFixed(2)} {i.currency}</h5>
<hr />
</div> </div>
))} ))}
</div> </div>
</div>
) )
} }
+9 -13
View File
@@ -1,19 +1,15 @@
import { h } from 'preact'; 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 ( return (
<div class="bundle"> <div class="offer">
<h3>{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}</h3> <h2>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h2>
{bundle.items.map(i => ( <h5 class='alt-text' >{details.year}</h5>
<div key={i.id}> <h5 class='alt-text'>{details.formats[0].descriptions.join(' ')}</h5>
<a href={i.uri} target="_blank" > <img src={details.thumb} />
<h4>{i.description}</h4> <h5>${offer.total}</h5>
</a>
<h5>{i.user.name}</h5>
<h5>{i.price} {i.currency}</h5>
<hr />
</div>
))}
</div> </div>
) )
} }
+2 -2
View File
@@ -18,7 +18,7 @@ function Bundles() {
if (!bundles.length) { if (!bundles.length) {
return ( return (
<div class={'home'}> <div class={'bundles'}>
loading... loading...
</div> </div>
); );
@@ -26,7 +26,7 @@ function Bundles() {
return ( return (
<div class={'home'}> <div class={'bundles'}>
{bundles.map(Bundle)} {bundles.map(Bundle)}
</div> </div>
); );
+3 -2
View File
@@ -11,6 +11,7 @@ function Home() {
async function getOffers() { async function getOffers() {
const [error, offers] = await actions.getOffers(); const [error, offers] = await actions.getOffers();
if (error) return console.error(error); if (error) return console.error(error);
console.log(offers);
setOffers(offers); setOffers(offers);
} }
getOffers(); getOffers();
@@ -18,7 +19,7 @@ function Home() {
if (!offers.length) { if (!offers.length) {
return ( return (
<div class={'home'}> <div class={'offers'}>
loading... loading...
</div> </div>
); );
@@ -26,7 +27,7 @@ function Home() {
return ( return (
<div class={'home'}> <div class={'offers'}>
{offers.map(Offer)} {offers.map(Offer)}
</div> </div>
); );
+22 -4
View File
@@ -283,12 +283,30 @@ header {
padding-bottom: var(--base-spacing); padding-bottom: var(--base-spacing);
} }
.home { .bundles {
display: flex; flex-flow: row;
.bundle { .bundle {
flex: 1; display: grid;
padding-right: 1em; 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;
}