acp stuff

This commit is contained in:
ntr committed 2022-08-23 23:35:25 +10:00
1 parent deb9eb3fc2
commit 047f606e83
14 files changed
+365 -41

No files matched your search

+2 -2
View File
@@ -51,8 +51,8 @@ describe('api', function apiTests() {
describe('offers', function offerTests() { describe('offers', function offerTests() {
it('create offers', async function createOffersTest() { it('create offers', async function createOffersTest() {
const { data: bundles } = await api.get('/bundles'); const { data: bundles } = await api.get('/bundles');
const { seller } = bundles[0]; const listingIds = bundles[0].items.map(i => i.id);
const { data } = await api.post('/offers', { seller, shipping: 50.0, baseShipping: 15.0 }); const { data } = await api.post('/offers', { listingIds, shipping: 50.0, baseShipping: 15.0 });
}); });
}); });
}); });
+37 -5
View File
@@ -6,7 +6,7 @@ import session from 'koa-session';
import koaRouter from '@koa/router' import koaRouter from '@koa/router'
import cors from '@koa/cors'; import cors from '@koa/cors';
import { groupBy } from 'lodash'; import { groupBy, sortBy } from 'lodash';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
import type Log from 'bunyan'; import type Log from 'bunyan';
@@ -104,14 +104,46 @@ export default function createApi(log: Log, pg: Pg, discogs, offers, events) {
ctx.status = 200; ctx.status = 200;
}); });
router.get('/bundles', async (ctx, next) => { router.get('/acp/bundles', async function bundlesGet(ctx, next) {
const bundles = await getBundles(); const bundles = await getBundles();
ctx.response.body = bundles; ctx.response.body = bundles;
}) })
router.post('/offers', async (ctx, next) => { router.get('/acp/offers', async function acpOffers(ctx, next) {
const { seller, shipping, baseShipping } = ctx.request.body; const offers = await pg.offersAdmin();
ctx.response.body = await offers.generate(seller, shipping, baseShipping);
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) { router.get('/user/offers', async function userOffersGet(ctx, next) {
+20 -5
View File
@@ -2,6 +2,7 @@ import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing, Offer, } from '@prisma/client'; import type { PrismaClient, Want, User, Listing, Offer, } from '@prisma/client';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { Context } from 'koa';
import { logAxiosError } from './utils'; import { logAxiosError } from './utils';
import { Pg } from './pg'; import { Pg } from './pg';
@@ -14,18 +15,22 @@ export enum Status {
} }
export default function createOffers(log: Log, pg: Pg) { export default function createOffers(log: Log, pg: Pg) {
async function generate(seller: number, bulkShipping: number, baseShipping: number): Offer[] { async function generate(listingIds: DiscogsListing[], bulkShipping: number, baseShipping: number) {
log.info('generateOffers', { seller, bulkShipping, baseShipping }); log.info('generateOffers', { listingIds, bulkShipping, baseShipping });
const listings = await pg.listingFindSeller(seller); const listings = await pg.listingByIds(listingIds);
log.info('creating offers for listings', { listings }) log.info({ listings }, 'creating offers for listings')
const shipping = bulkShipping / listings.length; const shipping = bulkShipping / listings.length;
// how to deal with users who don't want all items in a bundle...
const offers = listings.map(l => ({ const offers = listings.map(l => ({
userId: l.want.user.id, userId: l.want.user.id,
listingId: l.id, listingId: l.id,
wantId: l.want.id, wantId: l.want.id,
// sellerId: l.seller,
status: Status.Pending, status: Status.Pending,
item: l.data.price.value, item: l.data.price.value,
@@ -42,11 +47,21 @@ export default function createOffers(log: Log, pg: Pg) {
// and ensure the cheapest one goes in first // and ensure the cheapest one goes in first
const sorted = sortBy(offers, ['item']); const sorted = sortBy(offers, ['item']);
const inserts = await pg.offersCreate(sorted); const inserts = await pg.offersCreate(sorted);
console.log(inserts);
return inserts; 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);
}
return { return {
generate, generate,
accept,
}; };
} }
+45 -4
View File
@@ -3,6 +3,8 @@ import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing, Offer, 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';
import { Status } from './offers';
export interface UserCreateParams { export interface UserCreateParams {
email: string, email: string,
password: string, password: string,
@@ -14,10 +16,11 @@ export interface Pg {
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[]>, userOffers: (user: User) => Promise<Offer[]>,
userOfferFind: (user: User, id: UUID) => Promise<Offer>,
listingUpsert: (listing: Listing, want: Want) => Promise<Listing>, listingUpsert: (listing: Listing, want: Want) => Promise<Listing>,
listingFind: (id: DiscogsListing) => Promise<Listing>, listingFind: (id: DiscogsListing) => Promise<Listing>,
listingFindSeller: (id: DiscogsSeller) => Promise<Listing[]>, listingByIds: (ids: DiscogsListing[]) => Promise<Listing[]>,
listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise<number>, listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise<number>,
wantCreate: (discogsWant: any, user: User) => Promise<Want>, wantCreate: (discogsWant: any, user: User) => Promise<Want>,
@@ -27,6 +30,8 @@ export interface Pg {
bundleCandidates: () => Promise<Listing[][]>, bundleCandidates: () => Promise<Listing[][]>,
offersCreate: (offers: Offer[]) => Promise<Offer[]>, offersCreate: (offers: Offer[]) => Promise<Offer[]>,
offerSetStatus: (id: UUID, status: Status) => Promise<Offer>,
offersAdmin: () => Promise<Offer[]>,
} }
export default function createPg(log: Log, prisma: PrismaClient): Pg { export default function createPg(log: Log, prisma: PrismaClient): Pg {
@@ -45,6 +50,21 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
async function userOffers(user: User) { async function userOffers(user: User) {
return prisma.offer.findMany({ return prisma.offer.findMany({
where: { userId: user.id }, where: { userId: user.id },
include: {
want: true,
},
orderBy: {
status: 'desc',
},
});
}
async function userOfferFind(user: User, id: UUID) {
return prisma.offer.findFirstOrThrow({
where: {
userId: user.id,
id,
},
include: { include: {
want: true, want: true,
} }
@@ -145,10 +165,10 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
}); });
} }
async function listingFindSeller(seller: DiscogsSeller): Promise<Listing[]> { async function listingByIds(ids: DiscogsListing[]) {
return prisma.listing.findMany({ return prisma.listing.findMany({
where: { where: {
seller, id: { in: ids },
}, },
include: { include: {
want: { want: {
@@ -199,12 +219,30 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
); );
} }
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 { return {
userFindId, userFindId,
userFindEmail, userFindEmail,
userCreate, userCreate,
userWants, userWants,
userOffers, userOffers,
userOfferFind,
wantCreate, wantCreate,
wantList, wantList,
@@ -212,9 +250,12 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
listingUpsert, listingUpsert,
listingFind, listingFind,
listingRmUnlisted, listingRmUnlisted,
listingFindSeller, listingByIds,
bundleCandidates, bundleCandidates,
offersCreate, offersCreate,
offerSetStatus,
offersAdmin,
}; };
} }
+3 -4
View File
@@ -9,6 +9,7 @@
"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",
@@ -11861,8 +11862,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",
@@ -30319,8 +30319,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",
+1
View File
@@ -19,6 +19,7 @@
] ]
}, },
"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",
+19 -1
View File
@@ -33,13 +33,17 @@ api.interceptors.response.use(onSuccess, onError);
const actions = { const actions = {
getBundles: async function getBundles() { getBundles: async function getBundles() {
return api.get('/bundles'); return api.get('/acp/bundles');
}, },
getOffers: async function getOffers() { getOffers: async function getOffers() {
return api.get('/user/offers'); return api.get('/user/offers');
}, },
getOffersAcp: async function getOffersAcp() {
return api.get('/acp/offers');
},
getWants: async function getWants() { getWants: async function getWants() {
return api.get('/want/all'); return api.get('/want/all');
}, },
@@ -74,6 +78,20 @@ const actions = {
password, 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,
});
},
}; };
export default actions; export default actions;
+5 -1
View File
@@ -8,6 +8,7 @@ import Header from './components/header';
import Home from './routes/home'; import Home from './routes/home';
import Bundles from './routes/bundles'; import Bundles from './routes/bundles';
import Admin from './routes/acp';
import Login from './routes/login'; import Login from './routes/login';
import Search from './routes/search'; import Search from './routes/search';
import List from './routes/list'; import List from './routes/list';
@@ -36,10 +37,13 @@ function App() {
<Header user={user} /> <Header user={user} />
<Router> <Router>
<Route path="/" component={Home} /> <Route path="/" component={Home} />
<Route path="/bundles" component={Bundles} />
<Route path="/login" component={Login} success={loginSuccess} /> <Route path="/login" component={Login} success={loginSuccess} />
<Route path="/search" component={Search} /> <Route path="/search" component={Search} />
<Route path="/list" component={List} user={user} /> <Route path="/list" component={List} user={user} />
{/* ACP */}
<Route path="/bundles" component={Bundles} />
<Route path="/offers/acp" component={Admin} />
</Router> </Router>
</main> </main>
); );
+35
View File
@@ -0,0 +1,35 @@
import { h } from 'preact';
import { ExternalLink, Check } from 'react-feather';
import { useState } from 'preact/hooks';
import actions from '../actions';
export default function Offer(offer) {
const { want: { data: details } } = offer;
const [accepted, setAccepted] = useState(offer.status !== 'pending');
async function onClick() {
const [error] = await actions.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>
)
}
+56 -12
View File
@@ -1,7 +1,59 @@
import { h } from 'preact'; import { h } from 'preact';
import { Square, CheckSquare } from 'react-feather';
import { useState, useReducer } from 'preact/hooks';
import actions from '../actions';
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) { 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));
// 100.71
// 33.08
async function submit(e) {
e.preventDefault();
const [error, offers] = await actions.postOffers(state.checkedIds, shipping, baseShipping);
console.log(error, offers);
}
return ( return (
<div class="bundle"> <div class="bundle">
@@ -15,28 +67,20 @@ export default function Bundle(bundle) {
<form action=""> <form action="">
<div class="item"> <div class="item">
<label for="shipping">Full Shipping Cost</label> <label for="shipping">Full Shipping Cost</label>
<input type="number" name="shipping" id="shipping" required /> <input type="number" name="shipping" id="shipping" value={shipping} onChange={(e) => setShipping(Number.parseFloat(e.target.value))} step="0.01" min="0" required />
</div> </div>
<div class="item"> <div class="item">
<label for="baseShipping">Base Shipping Cost</label> <label for="baseShipping">Base Shipping Cost</label>
<input type="number" name="baseShipping" id="baseShipping" required /> <input type="number" name="baseShipping" id="baseShipping" value={baseShipping} onChange={(e) => setBaseShipping(Number.parseFloat(e.target.value))} step="0.01" min="0" required />
</div> </div>
<div class="item"> <div class="item">
<input type="submit" value="Generate Offers" /> <input type="submit" value="Generate Offers" onClick={(e) => submit(e)} />
</div> </div>
</form> </form>
</section> </section>
<div class='items'> <div class='items'>
{bundle.items.map(i => ( {items}
<div key={i.id}>
<a href={i.uri} target="_blank" >
<h4>{i.description}</h4>
</a>
<h5>{i.user.name}</h5>
<h5>{i.price.toFixed(2)} {i.currency}</h5>
</div>
))}
</div> </div>
</div> </div>
) )
+9 -1
View File
@@ -5,16 +5,24 @@ const Header = ({ user }) => {
const nav = () => ( const nav = () => (
<nav> <nav>
<a href="/">Offers</a> <a href="/">Offers</a>
<a href="/bundles">Bundles</a>
<a href='/list'>List</a> <a href='/list'>List</a>
<a href="/search">Search</a> <a href="/search">Search</a>
</nav> </nav>
); );
const adminNav = () => (
<nav>
<a href="/bundles">Bundles</a>
<a href='/offers/acp'>Offers ACP</a>
</nav>
)
return ( return (
<header> <header>
{user ? nav() : null} {user ? nav() : null}
<div></div> <div></div>
{user ? adminNav() : null}
</header> </header>
); );
} }
+22 -2
View File
@@ -1,15 +1,35 @@
import { h } from 'preact'; import { h } from 'preact';
import { ExternalLink } from 'react-feather'; import { ExternalLink, Check } from 'react-feather';
import { useState } from 'preact/hooks';
import actions from '../actions';
export default function Offer(offer) { export default function Offer(offer) {
const { want: { data: details } } = offer; const { want: { data: details } } = offer;
const [accepted, setAccepted] = useState(offer.status !== 'pending');
async function onClick() {
const [error] = await actions.postOfferAccept(offer.id);
if (error) return false;
setAccepted(true);
}
const classes = `${accepted ? 'accepted' : ''}`;
const btnText = accepted
? offer.status
: 'Accept';
return ( return (
<div class="offer"> <div class="offer">
<section>
<h2>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h2> <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.year}</h5>
<h5 class='alt-text'>{details.formats[0].descriptions.join(' ')}</h5> <h5 class='alt-text'>{details.formats[0].descriptions.join(' ')}</h5>
<h5 class='number'>${offer.total}</h5>
</section>
<img src={details.thumb} /> <img src={details.thumb} />
<h5>${offer.total}</h5> <button onClick={onClick} disabled={accepted} class={classes} >{btnText}</button>
</div> </div>
) )
} }
+66
View File
@@ -0,0 +1,66 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import actions from './../actions';
import AcpOffer from './../components/acp.offer';
function Item(item) {
console.log(item);
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>
</div>
);
}
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(Item)}
</div>
</div>
)
}
function Home() {
const [offers, setOffers] = useState([]);
useEffect(() => {
async function getOffers() {
const [error, offers] = await actions.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;
+45 -4
View File
@@ -19,7 +19,7 @@ ol, ul {
} }
img { img {
max-width: 100%; // max-width: 100%;
height: auto; height: auto;
} }
@@ -194,7 +194,7 @@ button, [type=submit] {
.alt-text { .alt-text {
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
font-size: var(--small-font-size); // font-size: var(--small-font-size);
text-transform: uppercase; text-transform: uppercase;
} }
@@ -279,8 +279,12 @@ a {
header { header {
display: grid; display: grid;
grid-template-columns: 1fr min-content 1fr; grid-template-columns: 1fr 1fr min-content;
padding-bottom: var(--base-spacing); padding-bottom: var(--base-spacing);
a {
white-space: nowrap;
}
} }
.bundles { .bundles {
@@ -289,7 +293,7 @@ header {
.bundle { .bundle {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
margin-bottom: var(--base-spacing); margin-bottom: var(--base-spacing-double);
.items { .items {
grid-column: 1 / -1; grid-column: 1 / -1;
@@ -300,6 +304,9 @@ header {
div { div {
margin-right: var(--base-spacing); margin-right: var(--base-spacing);
margin-bottom: var(--base-spacing); margin-bottom: var(--base-spacing);
display: grid;
grid-template-rows: 1fr 1fr 1fr;
} }
} }
} }
@@ -308,5 +315,39 @@ header {
.offers { .offers {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
.offer {
display: grid;
grid-template-areas:
"details img"
"btn btn";
padding: var(--base-spacing);
margin: var(--base-spacing-half);
background: var(--secondary-background-color);
section {
grid-area: details;
}
img {
grid-area: img;
justify-self: end;
margin-left: var(--base-spacing-half);
}
button {
grid-area: btn;
&.accepted {
background: var(--color-accent-blue);
cursor: auto;
}
}
}
} }
.number {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}