diff --git a/api/index.ts b/api/index.ts index 3e4c7bf..c87793e 100644 --- a/api/index.ts +++ b/api/index.ts @@ -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); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a1b913e..5afbe05 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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") +} diff --git a/api/src/api.test.ts b/api/src/api.test.ts index 94b7701..2ad32d0 100644 --- a/api/src/api.test.ts +++ b/api/src/api.test.ts @@ -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,29 +22,37 @@ after(function teardown(done) { }); describe('api', function apiTests() { - it('listens', async function listenTest() { - const res = await axios.get(`${HOST}/health`); - assert.equal(res.status, 200); + 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']); + }) }); - it('register', async function register() { - const body = { - email: `test+${Date.now()}@user.com`, - password: 'woaaah theres no limit', - }; - const { data } = await axios.post(`${HOST}/auth/register`, body); - assert.equal(data.email, body.email); + 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 }); + }); }); - - it('login', async function login() { - const body = { - 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); - assert(res.headers['set-cookie']); - }) - }); - diff --git a/api/src/api.ts b/api/src/api.ts index 537ff90..e6bdda8 100644 --- a/api/src/api.ts +++ b/api/src/api.ts @@ -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) { diff --git a/api/src/discogs.ts b/api/src/discogs.ts index 2cbdf94..0ed41e2 100644 --- a/api/src/discogs.ts +++ b/api/src/discogs.ts @@ -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' diff --git a/api/src/events.ts b/api/src/events.ts index e4127c0..5bf2ab8 100644 --- a/api/src/events.ts +++ b/api/src/events.ts @@ -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) { diff --git a/api/src/offers.ts b/api/src/offers.ts new file mode 100644 index 0000000..c7cd2dc --- /dev/null +++ b/api/src/offers.ts @@ -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, + }; +} diff --git a/api/src/pg.ts b/api/src/pg.ts index bc21d7b..ed8eb88 100644 --- a/api/src/pg.ts +++ b/api/src/pg.ts @@ -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, userCreate: (params: UserCreateParams) => Promise, userWants: (user: User) => Promise, + userOffers: (user: User) => Promise, + + listingUpsert: (listing: Listing, want: Want) => Promise, + listingFind: (id: DiscogsListing) => Promise, + listingFindSeller: (id: DiscogsSeller) => Promise, + listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise, wantCreate: (discogsWant: any, user: User) => Promise, wantList: (user: User) => Promise, wantsToSync: () => Promise, - listingUpsert: (listing: Listing, want: Want) => Promise, - listingFind: (id: DiscogsListing) => Promise, - listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise, bundleCandidates: () => Promise, + + offersCreate: (offers: Offer[]) => Promise, } 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 { + return prisma.listing.findMany({ + where: { + seller, + }, + include: { + want: { + include: { user: true }, + } + } + }); + } + async function bundleCandidates(): Promise { 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, }; } \ No newline at end of file diff --git a/api/src/services.ts b/api/src/services.ts index 2d8b349..899459c 100644 --- a/api/src/services.ts +++ b/api/src/services.ts @@ -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, diff --git a/client/disclaimers.txt b/client/disclaimers.txt index 29c565b..4d97f9d 100644 --- a/client/disclaimers.txt +++ b/client/disclaimers.txt @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/client/src/app.tsx b/client/src/app.tsx index ba9a562..67c1a19 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -20,7 +20,7 @@ function App() { const [error, user] = await actions.getLogin(); if (error) return route('/login'); setUser(user); - route('/search'); + route('/bundles'); } getLogin(); diff --git a/client/src/components/bundle.tsx b/client/src/components/bundle.tsx index 06bc6a8..5b53bb9 100644 --- a/client/src/components/bundle.tsx +++ b/client/src/components/bundle.tsx @@ -1,21 +1,43 @@ import { h } from 'preact'; export default function Bundle(bundle) { + + return (
-

{bundle.username} - {bundle.seller}

-

{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}

-

Ships: {bundle.mentionsAustralia && 'AU'} {bundle.mentionsWorldwide && 'WORLD'}

- {bundle.items.map(i => ( -
- -

{i.description}

-
-
{i.user.name}
-
{i.price} {i.currency}
-
-
- ))} +
+

{bundle.username} - {bundle.seller}

+

{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}

+

Ships: {bundle.mentionsAustralia && 'AU'} {bundle.mentionsWorldwide && 'WORLD'}

+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ {bundle.items.map(i => ( +
+ +

{i.description}

+
+
{i.user.name}
+
{i.price.toFixed(2)} {i.currency}
+
+ ))} +
) } diff --git a/client/src/components/offer.tsx b/client/src/components/offer.tsx index 1c81877..0c7509f 100644 --- a/client/src/components/offer.tsx +++ b/client/src/components/offer.tsx @@ -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 ( -
-

{bundle.items.length} items - {bundle.total.toFixed(2)} {bundle.currency}

- {bundle.items.map(i => ( -
- -

{i.description}

-
-
{i.user.name}
-
{i.price} {i.currency}
-
-
- ))} +
+

{details.artists_sort} - {details.title}

+
{details.year}
+
{details.formats[0].descriptions.join(' ')}
+ +
${offer.total}
) } diff --git a/client/src/routes/bundles.tsx b/client/src/routes/bundles.tsx index 6fbcb60..f188728 100644 --- a/client/src/routes/bundles.tsx +++ b/client/src/routes/bundles.tsx @@ -18,7 +18,7 @@ function Bundles() { if (!bundles.length) { return ( -
+
loading...
); @@ -26,7 +26,7 @@ function Bundles() { return ( -
+
{bundles.map(Bundle)}
); diff --git a/client/src/routes/home.tsx b/client/src/routes/home.tsx index c5ba943..9134122 100644 --- a/client/src/routes/home.tsx +++ b/client/src/routes/home.tsx @@ -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 ( -
+
loading...
); @@ -26,7 +27,7 @@ function Home() { return ( -
+
{offers.map(Offer)}
); diff --git a/client/src/style/index.less b/client/src/style/index.less index cf0c4d2..46d1253 100644 --- a/client/src/style/index.less +++ b/client/src/style/index.less @@ -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; +} +