diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index ac36a81..5645808 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -40,7 +40,7 @@ model User { model Want { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - release Int? + release Int data Json userId String @db.Uuid diff --git a/api/src/api.test.ts b/api/src/api.test.ts index 5955b92..94b7701 100644 --- a/api/src/api.test.ts +++ b/api/src/api.test.ts @@ -11,7 +11,6 @@ const services = createServices(log); const HOST = 'http://localhost:41337'; before(function setup() { - console.log('setup') services.api.listen(); }); diff --git a/api/src/api.ts b/api/src/api.ts index a9d3639..be75b45 100644 --- a/api/src/api.ts +++ b/api/src/api.ts @@ -14,8 +14,9 @@ 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) { +export default function createApi(log: Log, pg: Pg, discogs, events) { const PORT = 41337; const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth'; @@ -147,23 +148,40 @@ export default function createApi(log: Log, pg: Pg, discogs) { ctx.response.body = listings; }); - router.get('/wants', async function wantPost(ctx, next) { - const { userId, releaseId } = ctx.request.body; - const user = await pg.userFindId(userId); + 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.wantUpsert(release, user); - ctx.response.body = want; + const want = await pg.wantCreate([release], user); + + // fetch listings + const listings = await discogs.listings(want); + + ctx.response.body = { want, listings }; }); - router.get('/wants', async function wantGet(ctx, next) { + router.post('/want/discogs', async function wantListPost(ctx, next) { ctx.assert(ctx.isAuthenticated(), 401, 'log in required'); - log.warn(ctx.state.user); - const user = ctx.state.user; - const wants = await pg.wantFind(user); + const { username } = ctx.request.body; + const user = await pg.userFindId(ctx.state.user.id); + + const wants = await discogs.wantListSync(username, user); + + events.emit(Events.WantsAdded, wants); + ctx.response.body = wants; }); - router.post('/wants/search', async function wantPost(ctx, next) { + 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; diff --git a/api/src/discogs.ts b/api/src/discogs.ts index 20a5bfc..fdc81c9 100644 --- a/api/src/discogs.ts +++ b/api/src/discogs.ts @@ -31,30 +31,15 @@ export default function discogs(log: Log, pg: Pg) { return null; } - // async function user(discogs: string) { - // const user = await pg.userFindName(discogs); - // if (user) return user; - // log.debug('fetching user', discogs); - // const { data } = await api.get(`${apiBase}/users/${discogs}`); - // return pg.userUpsert(data.username, data.username, data); - // } - - async function wantlist(user: User): Promise { - const stored = await pg.userWants(user); - if (stored.length) return stored; - - if (!user.discogs) { - log.error(user, 'attempted to fetch wantlist of non discogs user'); - throw new Error('User is not associated with a discogs account'); - } - - return wantlistLatest(user); - } - - async function wantlistLatest(user: User): Promise { - const { username } = user.data as { username: string }; + async function wantListSync(username: string, user: User): Promise { 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))); + + // 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); + return additions; } async function release(id: DiscogsRelease): Promise { @@ -99,6 +84,7 @@ export default function discogs(log: Log, pg: Pg) { try { log.debug('fetching listing', id); const res = await api.get(`${apiBase}/marketplace/listings/${id}`); + log.info(res.data); return pg.listingUpsert(res.data, want); } catch (e) { return handleApiError(e); @@ -106,9 +92,7 @@ export default function discogs(log: Log, pg: Pg) { } return { - // user, - wantlist, - wantlistLatest, + wantListSync, inventory, listings, listing, diff --git a/api/src/events.ts b/api/src/events.ts new file mode 100644 index 0000000..38d462f --- /dev/null +++ b/api/src/events.ts @@ -0,0 +1,25 @@ +import { EventEmitter } from 'node:events'; + +import type Log from 'bunyan'; +import type { Listing, User, Want, Prisma } from '@prisma/client'; + +import { Pg } from './pg'; +import routerAuth from './routers/router.auth'; + +export enum Events { + WantsAdded = 'WantsAdded', +} + +export function eventService(log: Log, pg: Pg, discogs) { + const events = new EventEmitter({ captureRejections: true }); + + events.on('error', function eventError(e) { + log.error(e); + }); + + events.on(Events.WantsAdded, async function wantsAdded(wants: Want[]) { + log.info('wants added', wants); + }); + + return events; +} \ No newline at end of file diff --git a/api/src/pg.ts b/api/src/pg.ts index 24abb88..8484f1e 100644 --- a/api/src/pg.ts +++ b/api/src/pg.ts @@ -16,8 +16,8 @@ export interface Pg { userCreate: (params: UserCreateParams) => Promise, userWants: (user: User) => Promise, - wantUpsert: (want: Want, user: User) => Promise, - wantFind: (user: User) => Promise, + wantCreate: (discogsWant: any, user: User) => Promise, + wantList: (user: User) => Promise, listingUpsert: (listing: Listing, want: Want) => Promise, listingFind: (id: DiscogsListing) => Promise, listingRmUnlisted: (want: Want, ids: DiscogsListing[]) => Promise, @@ -60,22 +60,15 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg { }); } - async function wantUpsert(dcWant, user) { - const values: { userId: UUID, release: DiscogsListing, data: any } = { - data: dcWant, - release: dcWant.id, + async function wantCreate(discogsRelease, user) { + const data: { userId: UUID, release: DiscogsListing, data: any } = { + data: discogsRelease, + release: discogsRelease.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, + return prisma.want.create({ + data, }); } @@ -154,7 +147,8 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg { userCreate, userWants, - wantUpsert, + wantCreate, + wantList, listingUpsert, listingFind, listingRmUnlisted, diff --git a/api/src/services.ts b/api/src/services.ts index 89c7a04..2d8b349 100644 --- a/api/src/services.ts +++ b/api/src/services.ts @@ -2,20 +2,22 @@ import { PrismaClient } from '@prisma/client' import discogsService from './discogs'; import pgService from './pg'; -import { logAxiosError } from './utils'; import createLog from './log'; import createApi from './api'; +import { eventService } from './events'; export default function createServices(log) { const prisma = new PrismaClient(); const pg = pgService(log, prisma); const discogs = discogsService(log, pg); - const api = createApi(log, pg, discogs); + const events = eventService(log, pg, discogs); + const api = createApi(log, pg, discogs, events); return { - prisma, - pg, - discogs, api, + discogs, + events, + pg, + prisma, }; } \ No newline at end of file diff --git a/client/disclaimers.txt b/client/disclaimers.txt new file mode 100644 index 0000000..29c565b --- /dev/null +++ b/client/disclaimers.txt @@ -0,0 +1 @@ +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 diff --git a/client/package-lock.json b/client/package-lock.json index b93ef63..0fba0d3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "preact": "^10.10.0", "preact-render-to-string": "^5.2.1", "preact-router": "^3.2.1", + "react-feather": "^2.0.10", "react-redux": "^8.0.2", "redux": "^4.2.0", "unistore": "^3.5.2" @@ -12778,7 +12779,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -15798,7 +15798,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -15808,8 +15807,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -16113,6 +16111,17 @@ "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": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -31062,8 +31071,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-copy": { "version": "0.1.0", @@ -33326,7 +33334,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -33336,8 +33343,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, @@ -33580,6 +33586,14 @@ "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": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/client/package.json b/client/package.json index f7d2d88..c42a3b9 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,7 @@ "preact": "^10.10.0", "preact-render-to-string": "^5.2.1", "preact-router": "^3.2.1", + "react-feather": "^2.0.10", "react-redux": "^8.0.2", "redux": "^4.2.0", "unistore": "^3.5.2" diff --git a/client/src/actions.ts b/client/src/actions.ts index 0ce1214..a979840 100644 --- a/client/src/actions.ts +++ b/client/src/actions.ts @@ -49,7 +49,15 @@ const actions = { }, getWants: async function getWants() { - return api.get('/wants'); + return api.get('/want/all'); + }, + + 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() { diff --git a/client/src/app.tsx b/client/src/app.tsx index 5c32bc3..25a51e6 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -9,7 +9,8 @@ import Header from './components/header'; import Home from './routes/home'; import Login from './routes/login'; import Search from './routes/search'; -import User from './routes/user'; +import List from './routes/list'; +import Offers from './routes/offers'; function App() { const [user, setUser] = useState(null); @@ -38,7 +39,8 @@ function App() { - + + ); diff --git a/client/src/components/header.tsx b/client/src/components/header.tsx index 58e689d..2f6e3c7 100644 --- a/client/src/components/header.tsx +++ b/client/src/components/header.tsx @@ -4,7 +4,7 @@ import { Link } from 'preact-router/match'; const Header = ({ user }) => { const nav = () => ( diff --git a/client/src/components/want.tsx b/client/src/components/want.tsx new file mode 100644 index 0000000..f8d432e --- /dev/null +++ b/client/src/components/want.tsx @@ -0,0 +1,20 @@ +import { h } from 'preact'; + +function Want(want) { + console.log(want); + const r = want.data; + + return ( +
+
+

{r.artists_sort} - {r.title}

+
{r.year}
+
{r.country}
+
{r.formats[0].descriptions.join(' ')}
+
+ +
+ ); +} + +export default Want; \ No newline at end of file diff --git a/client/src/routes/home.tsx b/client/src/routes/home.tsx index 357134a..807320c 100644 --- a/client/src/routes/home.tsx +++ b/client/src/routes/home.tsx @@ -4,12 +4,17 @@ import { useState, useEffect } from 'preact/hooks'; import actions from './../actions'; import Bundle from './../components/bundle'; - function Home() { const [bundles, setBundles] = useState([]); useEffect(() => { - actions.getBundles().then(setBundles); + async function getBundles() { + const [error, bundles] = await actions.getBundles(); + if (error) return console.error(error); + console.log(bundles, 'bundles') + setBundles(bundles); + } + getBundles(); }, []); if (!bundles.length) { diff --git a/client/src/routes/user.tsx b/client/src/routes/list.tsx similarity index 59% rename from client/src/routes/user.tsx rename to client/src/routes/list.tsx index 561aaea..2a585dd 100644 --- a/client/src/routes/user.tsx +++ b/client/src/routes/list.tsx @@ -3,40 +3,37 @@ import { useState, useEffect } from 'preact/hooks'; // import style from './style.less'; import actions from './../actions'; -import Bundle from './../components/bundle'; +import Want from './../components/want'; interface Props { user: any, - offers: any[], + wants: any[], getUserOffers: (userId: string) => void, } -const User = ({ user }: Props) => { - const [offers, setOffers] = useState([]); +const List = ({ user }: Props) => { + const [wants, setOffers] = useState([]); useEffect(() => { async function getWants() { const [error, wants] = await actions.getWants(); if (error) return console.error(error); + console.log(wants, 'wants') setOffers(wants); } getWants(); }, []); - if (!offers.length) { - return ( -
- loading... -
- ); + if (!wants.length) { + return
loading...
; } return ( -
- {offers.map(Bundle)} +
+ {wants.map(Want)}
); }; -export default User; +export default List; diff --git a/client/src/routes/offers.tsx b/client/src/routes/offers.tsx new file mode 100644 index 0000000..cb14a0e --- /dev/null +++ b/client/src/routes/offers.tsx @@ -0,0 +1,70 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import axios from 'axios'; + +import actions from '../actions'; + +function result(r) { + function onClick() { + actions.postWant(r.id); + } + + return ( +
+ +
+

{r.title}

+
{r.year}
+
{r.country}
+
{r.format.join(' ')}
+
+ +
+ ); +} + +const User = () => { + const [results, setResults] = useState([]); + const [noResults, setNoResults] = useState(false); + + async function fetchResults() { + const { data } = await actions.getBundles(); + + console.log(data); + + if (data.length === 0) { + setResults([]); + return setNoResults(true); + } + + setNoResults(false); + setResults(data); + } + + function handleSubmit(e) { + e.preventDefault(); + fetchResults(); + } + + const topEl = results.length + ? + : null; + + const noResultsEl = noResults + ?
No Results
+ : null; + + return ( + + ); +}; + +export default User; diff --git a/client/src/routes/search.tsx b/client/src/routes/search.tsx index 16a2889..5dcdfd5 100644 --- a/client/src/routes/search.tsx +++ b/client/src/routes/search.tsx @@ -1,13 +1,20 @@ import { h } from 'preact'; import { useState } from 'preact/hooks'; import axios from 'axios'; +import { ExternalLink } from 'react-feather'; + +import actions from '../actions'; function result(r) { + function onClick() { + actions.postWant(r.id); + } + return (
- +
-

{r.title}

+

{r.title}

{r.year}
{r.country}
{r.format.join(' ')}
@@ -20,11 +27,12 @@ function result(r) { const User = () => { const [artist, setArtist] = useState('nine inch nails') const [title, setTitle] = useState('the fragile'); + const [username, setUsername] = useState(''); const [results, setResults] = useState([]); const [noResults, setNoResults] = useState(false); async function fetchResults() { - const { data } = await axios.post(`http://localhost:41337/wants/search`, { + const { data } = await axios.post(`http://localhost:41337/want/search`, { artist, title, }); @@ -40,11 +48,22 @@ const User = () => { setResults(data); } + async function submitWantlist() { + const [error, wants] = await actions.postDiscogs(username); + // setNoResults(false); + // setResults(data); + } + function handleSubmit(e) { e.preventDefault(); fetchResults(); } + function handleDiscogsSubmit(e) { + e.preventDefault(); + submitWantlist(); + } + const topEl = results.length ? : null; @@ -55,14 +74,26 @@ const User = () => { return (