2 Commits
Author SHA1 Message Date
ntr c99b148e15 sample wip 2022-11-26 18:22:13 +11:00
ntr ffc023f419 Merge branch 'master' into sample-offers 2022-11-26 17:31:14 +11:00
58 changed files with 1027 additions and 5742 deletions

No files matched your search

-4
View File
@@ -1,7 +1,3 @@
node_modules/ node_modules/
data/ data/
db/ db/
.env
*sql.gz
+12 -24
View File
@@ -1,30 +1,18 @@
# TODO # TODO
- accept new offers that are under highest accepted price listing ids overflowing
env
discogs api key
discord api key
discord bot
notify on sign up
want added
offer accepted
email ppl who signed up
say we have updates
main list filters
order by
offers only
sample offer with tooltips
go over and reorganise event logic
should be connection.service ?
contact options on landing page
skip unofficial release skip unofficial release
status stepper status stepper
queue up wantlist adds to gracefully handle rate limiting
push offers
cover off admin routes and commands
listen / notify
on offers
notify admins
-1
View File
@@ -1 +0,0 @@
node_modules/
+7
View File
@@ -0,0 +1,7 @@
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="postgresql://mcrates:digdeep@0.0.0.0:45432/mcrates?schema=public"
-14
View File
@@ -1,14 +0,0 @@
FROM node:20
WORKDIR /opt/crates/api
# puppeteer deps
RUN apt-get update
# don't think about it
RUN apt-get install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils
COPY . ./
RUN npm i
# not sure why doesn't do it itself
RUN node node_modules/puppeteer/install.mjs
CMD ["npm", "start"]
-1
View File
@@ -1 +0,0 @@
crates-api-7b8d754768-r4f5q 19:40:38.635Z ERROR mcrates: sync error ││ crates-api-7b8d754768-r4f5q TypeError: Cannot read properties of undefined (reading 'status') ││ crates-api-7b8d754768-r4f5q at populateListings (/opt/crates/api/src/events.ts:42:28) ││ crates-api-7b8d754768-r4f5q at updateWantListings (/opt/crates/api/src/events.ts:57:30) ││ crates-api-7b8d754768-r4f5q at EventEmitter.sync (/opt/crates/api/src/events.ts:83:13) ││ crates-api-7b8d754768-r4f5q 19:40:40.707Z INFO mcrates: (event=Sync, wants=192) ││ crates-api-7b8d754768-r4f5q 19:42:14.756Z ERROR mcrates: Navigation timeout of 30000 ms exceeded ││ crates-api-7b8d754768-r4f5q TimeoutError: Navigation timeout of 30000 ms exceeded ││ crates-api-7b8d754768-r4f5q at new Deferred (/opt/crates/api/node_modules/puppeteer-core/src/util/Deferred.ts:73:2 ││ crates-api-7b8d754768-r4f5q at Function.create (/opt/crates/api/node_modules/puppeteer-core/src/util/Deferred.ts:2 ││ crates-api-7b8d754768-r4f5q at new LifecycleWatcher (/opt/crates/api/node_modules/puppeteer-core/src/cdp/Lifecycle ││ crates-api-7b8d754768-r4f5q at CdpFrame.goto (/opt/crates/api/node_modules/puppeteer-core/src/cdp/Frame.ts:159:21) ││ crates-api-7b8d754768-r4f5q at CdpFrame.<anonymous> (/opt/crates/api/node_modules/puppeteer-core/src/util/decorato ││ crates-api-7b8d754768-r4f5q at CdpPage.goto (/opt/crates/api/node_modules/puppeteer-core/src/api/Page.ts:1649:35) ││ crates-api-7b8d754768-r4f5q at Object.listings (/opt/crates/api/src/discogs.ts:80:32) ││ crates-api-7b8d754768-r4f5q at populateListings (/opt/crates/api/src/events.ts:37:30) ││ crates-api-7b8d754768-r4f5q at updateWantListings (/opt/crates/api/src/events.ts:57:30) ││ crates-api-7b8d754768-r4f5q at EventEmitter.sync (/opt/crates/api/src/events.ts:83:13) ││ crates-api-7b8d754768-r4f5q 19:42:14.989Z ERROR mcrates: sync error ││ crates-api-7b8d754768-r4f5q TypeError: Cannot read properties of undefined (reading 'status') ││ crates-api-7b8d754768-r4f5q at populateListings (/opt/crates/api/src/events.ts:42:28) ││ crates-api-7b8d754768-r4f5q at updateWantListings (/opt/crates/api/src/events.ts:57:30) ││ crates-api-7b8d754768-r4f5q at EventEmitter.sync (/opt/crates/api/src/events.ts:83:13) ││ crates-api-7b8d754768-r4f5q 19:42:25.047Z INFO mcrates: (event=Sync, wants=202) ││ crates-api-7b8d754768-r4f5q 19:44:01.988Z ERROR mcrates: Timed out after waiting 30000ms ││ crates-api-7b8d754768-r4f5q TimeoutError: Timed out after waiting 30000ms ││ crates-api-7b8d754768-r4f5q at /opt/crates/api/node_modules/puppeteer-core/src/common/util.ts:302:17 ││ crates-api-7b8d754768-r4f5q at /opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_party/rxjs/rxjs.js:3986:3 ││ crates-api-7b8d754768-r4f5q at OperatorSubscriber2._this._next (/opt/crates/api/node_modules/puppeteer-core/lib/cj ││ crates-api-7b8d754768-r4f5q at OperatorSubscriber2.Subscriber2.next (/opt/crates/api/node_modules/puppeteer-core/l ││ crates-api-7b8d754768-r4f5q at AsyncAction2.<anonymous> (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third ││ crates-api-7b8d754768-r4f5q at AsyncAction2._execute (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_pa ││ crates-api-7b8d754768-r4f5q at AsyncAction2.execute (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_par ││ crates-api-7b8d754768-r4f5q at AsyncScheduler2.flush (/opt/crates/api/node_modules/puppeteer-core/lib/cjs/third_pa ││ crates-api-7b8d754768-r4f5q at listOnTimeout (node:internal/timers:581:17) ││ crates-api-7b8d754768-r4f5q at processTimers (node:internal/timers:519:7) ││ crates-api-7b8d754768-r4f5q 19:44:04.209Z ERROR mcrates: sync error ││ crates-api-7b8d754768-r4f5q TypeError: Cannot read properties of undefined (reading 'status') ││ crates-api-7b8d754768-r4f5q at populateListings (/oLine truncated
+3 -7
View File
@@ -24,16 +24,12 @@ async function start() {
prisma, prisma,
pg, pg,
discogs, discogs,
discord,
api, api,
events, events,
notify, } = createServices(log);
} = await createServices(log);
await notify.listen(); // events.emit(Events.StartupSync);
await discord.connect(); // events.emit(Events.ListingsUpdated);
// events.emit(Events.Sync);
api.listen(); api.listen();
} }
Generated Executable → Regular
+189 -4030
View File
File diff suppressed because it is too large. Load diff
+4 -9
View File
@@ -1,12 +1,12 @@
{ {
"name": "crates", "name": "mates-crates",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "mocha", "test": "mocha",
"test:watch": "mocha --watch", "test:watch": "mocha --watch",
"migrate": "prisma migrate status", "migrate": "knex migrate:latest",
"migrate:make": "prisma migrate dev --name", "migrate:make": "prisma migrate dev --name",
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short" "start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
}, },
@@ -21,6 +21,7 @@
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"axios": "^0.27.2", "axios": "^0.27.2",
"mocha": "^10.0.0", "mocha": "^10.0.0",
"node-dev": "^7.4.3",
"prisma": "^4.5.0", "prisma": "^4.5.0",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^4.7.4" "typescript": "^4.7.4"
@@ -33,8 +34,6 @@
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"bunyan": "^1.8.15", "bunyan": "^1.8.15",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"discord.js": "^14.7.1",
"esrun": "^3.2.26",
"koa": "^2.13.4", "koa": "^2.13.4",
"koa-body": "^5.0.0", "koa-body": "^5.0.0",
"koa-passport": "^5.0.0", "koa-passport": "^5.0.0",
@@ -42,11 +41,7 @@
"koa-websocket": "^7.0.0", "koa-websocket": "^7.0.0",
"koa-ws": "^1.0.1", "koa-ws": "^1.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport-local": "^1.0.0", "passport-local": "^1.0.0"
"pg-listen": "^1.7.0",
"puppeteer": "^23.3.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2"
}, },
"overrides": { "overrides": {
"koa-passport": { "koa-passport": {
@@ -1,81 +0,0 @@
-- CreateTable
CREATE TABLE "listings" (
"id" BIGINT NOT NULL,
"seller" BIGINT NOT NULL,
"data" JSONB NOT NULL,
"wantId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "listings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wants" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"release" BIGINT NOT NULL,
"data" JSONB NOT NULL,
"userId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "offers" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"status" TEXT NOT NULL,
"condition" TEXT NOT NULL DEFAULT '',
"item" MONEY NOT NULL,
"shipping" MONEY NOT NULL,
"total" MONEY NOT NULL,
"listingId" BIGINT NOT NULL,
"userId" UUID NOT NULL,
"wantId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "offers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "listings_seller_idx" ON "listings"("seller");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "wants_release_idx" ON "wants"("release");
-- CreateIndex
CREATE UNIQUE INDEX "wants_userId_release_key" ON "wants"("userId", "release");
-- CreateIndex
CREATE UNIQUE INDEX "offers_listingId_key" ON "offers"("listingId");
-- AddForeignKey
ALTER TABLE "listings" ADD CONSTRAINT "listings_wantId_fkey" FOREIGN KEY ("wantId") REFERENCES "wants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wants" ADD CONSTRAINT "wants_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "offers" ADD CONSTRAINT "offers_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "listings"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "offers" ADD CONSTRAINT "offers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "offers" ADD CONSTRAINT "offers_wantId_fkey" FOREIGN KEY ("wantId") REFERENCES "wants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,111 +0,0 @@
CREATE OR REPLACE FUNCTION notify_users() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id UUID;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('users.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_listings() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id int8;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('listings.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_offers() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id UUID;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('offers.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_wants() RETURNS TRIGGER AS $$
DECLARE
record RECORD;
id UUID;
payload JSON;
BEGIN
IF (TG_OP = 'DELETE') THEN
id = OLD.id;
ELSE
id = NEW.id;
END IF;
payload = json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'id', id
);
PERFORM pg_notify('wants.events', payload::text);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER notify_wants_event
AFTER INSERT OR UPDATE OR DELETE ON wants
FOR EACH ROW EXECUTE PROCEDURE notify_wants();
CREATE OR REPLACE TRIGGER notify_listings_event
AFTER INSERT OR UPDATE OR DELETE ON listings
FOR EACH ROW EXECUTE PROCEDURE notify_listings();
CREATE OR REPLACE TRIGGER notify_offers_event
AFTER INSERT OR UPDATE OR DELETE ON offers
FOR EACH ROW EXECUTE PROCEDURE notify_offers();
CREATE OR REPLACE TRIGGER notify_users_event
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE PROCEDURE notify_users();
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+7 -15
View File
@@ -11,13 +11,8 @@ datasource db {
} }
model Listing { model Listing {
id Int @id
// example listingId 2148851036 seller Int
// works fine as a JS number
// but JS numbers are cast to int4 for pg by prisma
// becomes -2146116260
id BigInt @id
seller BigInt
data Json data Json
wantId String @db.Uuid wantId String @db.Uuid
@@ -26,7 +21,7 @@ model Listing {
offers Offer[] offers Offer[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @updatedAt
@@index([seller]) @@index([seller])
@@map("listings") @@map("listings")
@@ -38,7 +33,7 @@ model User {
password String password String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @updatedAt
wants Want[] wants Want[]
offers Offer[] offers Offer[]
@@ -48,14 +43,14 @@ model User {
model Want { model Want {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
release BigInt release Int
data Json data Json
userId String @db.Uuid userId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @updatedAt
listings Listing[] listings Listing[]
offers Offer[] offers Offer[]
@@ -76,7 +71,7 @@ model Offer {
shipping Decimal @db.Money shipping Decimal @db.Money
total Decimal @db.Money total Decimal @db.Money
listingId BigInt listingId Int
listing Listing @relation(fields: [listingId], references: [id]) listing Listing @relation(fields: [listingId], references: [id])
userId String @db.Uuid userId String @db.Uuid
@@ -85,9 +80,6 @@ model Offer {
wantId String @db.Uuid wantId String @db.Uuid
want Want @relation(fields: [wantId], references: [id], onDelete: Cascade) want Want @relation(fields: [wantId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
// only one offer per want for a user // only one offer per want for a user
// @@unique([userId, wantId]) // @@unique([userId, wantId])
@@unique([listingId]) @@unique([listingId])
+8
View File
@@ -48,4 +48,12 @@ describe('api', function apiTests() {
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 listingIds = bundles[0].items.map(i => i.id);
const { data } = await api.post('/offers', { listingIds, shipping: 50.0, baseShipping: 15.0 });
});
});
}); });
+32 -17
View File
@@ -13,11 +13,9 @@ import Bluebird from 'bluebird';
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { Listing, User, Want, Prisma } from '@prisma/client'; import type { Listing, User, Want, Prisma } from '@prisma/client';
import type { Pg } from './pg'; import { Pg } from './pg';
import type { Discogs } from './discogs'; import { Discogs } from './discogs';
import type { Offers } from './offers'; import { Offers } from './offers';
import type { Rpc } from './rpc';
import routerAuth from './routers/router.auth'; import routerAuth from './routers/router.auth';
import { Events } from './events'; import { Events } from './events';
import createRpc, { RpcMessageType } from './rpc'; import createRpc, { RpcMessageType } from './rpc';
@@ -29,11 +27,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
const app = koaWebsocket(new koa()); const app = koaWebsocket(new koa());
// @ts-ignore - ts don't know wtf it talking about
BigInt.prototype.toJSON = function bigIntStringify() {
return this.toString();
};
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
try { try {
await next(); await next();
@@ -51,7 +44,6 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
function listen() { function listen() {
const server = app.listen(PORT); const server = app.listen(PORT);
log.info({ PORT }, 'listening'); log.info({ PORT }, 'listening');
return server;
} }
app.use(cors({ credentials: true })); app.use(cors({ credentials: true }));
@@ -60,7 +52,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
app.keys = [SECRET]; app.keys = [SECRET];
app.use(session(app)); app.use(session(app));
const { authRouter, passport } = routerAuth(log, pg, events); const { authRouter, passport } = routerAuth(log, pg);
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()) app.use(passport.session())
app.use(authRouter.routes()); app.use(authRouter.routes());
@@ -69,13 +61,37 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
ctx.status = 200; ctx.status = 200;
}); });
router.get('/user/offers', async function userOffersGet(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const user = ctx.state.user;
const offers = await pg.userOffers(user);
ctx.response.body = offers;
});
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.wantCreate(release, user);
events.emit(Events.WantsAdded, [want]);
ctx.response.body = [want];
});
router.post('/want/discogs', async function wantListPost(ctx, next) { router.post('/want/discogs', async function wantListPost(ctx, next) {
ctx.assert(ctx.isAuthenticated(), 401, 'log in required'); ctx.assert(ctx.isAuthenticated(), 401, 'log in required');
const { username } = ctx.request.body; const { username } = ctx.request.body;
ctx.assert(username, 'no username supplied');
const user = await pg.userFindId(ctx.state.user.id); const user = await pg.userFindId(ctx.state.user.id);
const wants = await discogs.wantListSync(username, user); const wants = await discogs.wantListSync(username, user);
events.emit(Events.WantsAdded, wants); events.emit(Events.WantsAdded, [wants]);
ctx.response.body = wants;
});
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; ctx.response.body = wants;
}); });
@@ -104,8 +120,9 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
// log.child... // log.child...
const ws = ctx.websocket; const ws = ctx.websocket;
const rpc = createRpc(log, pg, ws, discogs, offers, events, ctx.state.user as User | null); const rpc = createRpc(log, pg, ws, discogs, offers, ctx.state.user);
if (ctx.state.user) events.emit(Events.Connection, { ws, id: ctx.state.user.id });
ws.on('message', async function incoming(message) { ws.on('message', async function incoming(message) {
try { try {
const parsed = JSON.parse(message.toString()); const parsed = JSON.parse(message.toString());
@@ -123,9 +140,7 @@ export default function createApi(log: Log, pg: Pg, discogs: Discogs, offers: Of
log.info('ws disconnected', { user: ctx.state.user }); log.info('ws disconnected', { user: ctx.state.user });
}); });
if (ctx.state.user) events.emit(Events.Connection, { rpc, id: ctx.state.user.id });
await rpc.onConnection(); await rpc.onConnection();
} catch (e) { } catch (e) {
log.error(e); log.error(e);
} }
+40 -56
View File
@@ -1,21 +1,23 @@
import fs from 'fs/promises';
import axios from 'axios'; import axios from 'axios';
import pptr from 'puppeteer-extra' import cheerio from 'cheerio';
import StealthPlugin from 'puppeteer-extra-plugin-stealth' import Bluebird from 'bluebird';
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { Want, User } from '@prisma/client'; import type { PrismaClient, Want, User, Listing } from '@prisma/client';
import { wantDetails } from './utils'; import { logAxiosError, wantDetails } from './utils';
import { Pg } from './pg'; import { Pg } from './pg';
export default async function createDiscogs(log: Log, pg: Pg) { // margh
const TOKEN = process.env.DISCOGS_TOKEN; // vanderbabe
// jmcdermid1
export default function createDiscogs(log: Log, pg: Pg) {
const TOKEN = 'Discogs token=wRTXuYZGOPsIQgKRGKCAkUkidGtvpSfQgPWkYIpD';
const apiBase = 'https://api.discogs.com' const apiBase = 'https://api.discogs.com'
const discogsBase = 'https://www.discogs.com'
pptr.use(StealthPlugin())
const browser = await pptr.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] })
log.info('browser created')
const api = axios.create({ const api = axios.create({
headers: { headers: {
@@ -25,37 +27,34 @@ export default async function createDiscogs(log: Log, pg: Pg) {
}, },
}); });
function onSuccess(response) { async function handleApiError(e) {
return response.data; logAxiosError(e);
if (e.response.status === 429) {
throw new Error('rate limited');
} }
return null;
function onError(error) {
throw error;
} }
api.interceptors.response.use(onSuccess, onError);
async function wantListSync(username: string, user: User): Promise<Want[]> { async function wantListSync(username: string, user: User): Promise<Want[]> {
const res = await api.get(`${apiBase}/users/${username}/wants`); const { data: { wants, pagination }} = await api.get(`${apiBase}/users/${username}/wants`);
const { wants, pagination } = res;
const releases = await Promise.all(wants.map(want => release(want.id))); const releases = await Promise.all(wants.map(want => release(want.id)));
log.info({ wants: wants.length, user: username, userId: user.id }, 'fetched discogs wantlist'); log.info({ wants: wants.length, user: username, userId: user.id }, 'fetched discogs wantlist');
// TODO filter releases to be vinyl format only // TODO filter releases to be vinyl format only
// convert want object to actual release and ignore duplicate errors // convert want object to actual release and ignore duplicate errors
const results = await Promise.allSettled(releases.map(release => pg.wantCreate(release, user))); const results = await Promise.allSettled(releases.map(release => pg.wantCreate(release, user)));
log.info(results);
// @ts-ignore // @ts-ignore
const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value); const additions = results.filter(r => r.status === 'fulfilled').map(r => r.value);
log.info('want list additions', { additions: additions.length }); log.info(additions);
return additions; return additions;
} }
async function release(id: DiscogsRelease): Promise<any> { async function release(id: DiscogsRelease): Promise<any> {
const data = await api.get(`${apiBase}/releases/${id}`); const { data } = await api.get(`${apiBase}/releases/${id}`);
return data; return data;
} }
@@ -64,59 +63,44 @@ export default async function createDiscogs(log: Log, pg: Pg) {
url.searchParams.append('query', query); url.searchParams.append('query', query);
url.searchParams.append('type', 'release'); url.searchParams.append('type', 'release');
url.searchParams.append('format', 'vinyl'); url.searchParams.append('format', 'vinyl');
const res = await api.get(url.href); const { data: { results } } = await api.get(url.href);
const { results } = res;
return results; return results;
} }
async function inventory(user: string) { async function inventory(user: string) {
const res = await api.get(`${apiBase}/users/${user}/inventory`); const res = await api.get(`${apiBase}/users/${user}/inventory`);
return res.listings; return res.data.listings;
} }
async function listings(want: Want) { async function listings(want: Want) {
const page = await browser.newPage() const res = await api.get(`https://www.discogs.com/sell/release/${want.release}?limit=250`);
const url = `https://www.discogs.com/sell/release/${want.release}?limit=250` const $ = cheerio.load(res.data);
const res = await page.goto(url) const ids = $('.item_description_title[href]')
log.debug({ status: res.status() }, 'status') .map(function getListingId() {
if (!res.ok()) throw new Error(res.statusText()) const id = Number.parseInt(this.attribs.href.replace('/sell/item/', ''), 10);
const els = await page.$$('.item_description_title[href]') return id;
const hrefs = await Promise.all(els.map(e => e.evaluate(e => e.href))) })
const ids = hrefs .get();
.map(h => new URL(h))
.map(p => p.pathname.replace('/sell/item/', ''))
.map(p => Number.parseInt(p, 10))
await page.close()
if (ids.length === 0) log.warn({ res, url, ...wantDetails(want) }, "Want had zero listings")
const unlisted = await pg.listingRmUnlisted(want, ids); const unlisted = await pg.listingRmUnlisted(want, ids);
if (unlisted) log.info({ unlisted: unlisted, ...wantDetails(want) }, 'missing listings removed'); if (unlisted) log.info({ unlisted: unlisted, ...wantDetails(want) }, 'missing listings removed');
// preferentially take listings from sellers if we hit req limits // preferentially take listings from sellers if we hit req limits
return Bluebird.mapSeries(ids, async id => listing(id, want));
// sometimes we get dodgy listings from discogs that throw perm errors
// we just want to ignore them
// const results = await Promise.allSettled(ids.map(id => listing(id, want)));
const results = []
for await (const id of ids) {
const r = await listing(id, want)
results.push(r)
}
return results;
} }
async function listing(id, want) { async function listing(id, want) {
const listing = await pg.listingFind(id); const listing = await pg.listingFind(id);
if (listing) return listing; if (listing) return listing;
try {
log.debug('fetching listing', id); log.debug('fetching listing', id);
const discogsListing = await api.get(`${apiBase}/marketplace/listings/${id}`); const res = await api.get(`${apiBase}/marketplace/listings/${id}`);
log.debug(discogsListing); log.debug(res.data);
return pg.listingUpsert(discogsListing, want); return pg.listingUpsert(res.data, want);
} catch (e) {
return handleApiError(e);
}
} }
return { return {
-36
View File
@@ -1,36 +0,0 @@
import type Log from 'bunyan';
import { Client, GatewayIntentBits } from 'discord.js';
export default function createDiscord(log: Log) {
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const TOKEN = process.env.DISCORD_TOKEN;
async function connect() {
return new Promise((resolve, reject) => {
client.once('ready', () => {
log.info({ bot: client.user.tag }, 'discord client logged in');
resolve(client);
});
client.login(TOKEN);
});
}
function message(msg) {
if (!process.env.DISCORD_ENABLED) return false;
log.info({ msg }, 'sending discord message');
const channel = client.channels.cache.get('1072490127324090418');
// @ts-ignore - wrong
const str = JSON.stringify(msg, null, 4);
channel.send(`\`\`\`${str}\`\`\``);
}
return {
connect,
message,
};
}
export type Discord = ReturnType<typeof createDiscord>;
+25 -80
View File
@@ -2,127 +2,72 @@ import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'timers/promises';
import type Log from 'bunyan'; import type Log from 'bunyan';
import type { Want, Offer } from '@prisma/client'; import type { Want } from '@prisma/client';
import { Pg } from './pg'; import { Pg } from './pg';
import { Rpc } from './rpc';
import { wantDetails } from './utils'; import { wantDetails } from './utils';
import routerAuth from './routers/router.auth'; import routerAuth from './routers/router.auth';
export enum Events { export enum Events {
WantsAdded = 'WantsAdded', WantsAdded = 'WantsAdded',
Sync = 'Sync', StartupSync = 'StartupSync',
Connection = 'Connection',
ListingsUpdated = 'ListingsUpdated', ListingsUpdated = 'ListingsUpdated',
OffersUpdated = 'OffersUpdated', Connection = 'Connection',
WantsUpdated = 'WantsUpdated',
DiscordMessage = 'DiscordMessage',
} }
export default function createEvents(log: Log, pg: Pg, discogs, offers, discord) { export default function createEvents(log: Log, pg: Pg, discogs, offers) {
const events = new EventEmitter({ captureRejections: true }); const events = new EventEmitter({ captureRejections: true });
const connections = new Map<UUID, Rpc>(); const connections = new Map();
events.on('error', function eventError(e) { async function populateListings(want: Want) {
log.error('event error', e);
});
const MAX_ATTEMPTS = 3;
const SYNC_EVERY_MS = 60 * 1000
const RATE_LIMIT_WAIT_MS = (attempts) => attempts * 70 * 1000
async function populateListings(want: Want, attempts = 1) {
try { try {
const listings = await discogs.listings(want); const listings = await discogs.listings(want);
await pg.wantSynced(want);
return listings; return listings;
} catch (e) { } catch (e) {
log.error(e) if (e.message === 'rate limited') {
if (e.response.status === 429) { log.warn('rate limited, trying again in 60s');
log.warn({ RATE_LIMIT_WAIT_MS: RATE_LIMIT_WAIT_MS(attempts), ...e.response.headers }, 'rate limited, trying again'); await setTimeout(60 * 1000);
return populateListings(want);
await setTimeout(RATE_LIMIT_WAIT_MS(attempts));
if (attempts > MAX_ATTEMPTS) {
log.error(wantDetails(want), 'failed to populate listings')
}
return populateListings(want, attempts++);
} }
throw e; throw e;
} }
} }
events.on('error', function eventError(e) {
log.error('event error', e);
});
async function updateWantListings(wants: Want[]) { async function updateWantListings(wants: Want[]) {
for (const want of wants) { for (const want of wants) {
const listings = await populateListings(want); const listings = await populateListings(want);
log.info({ listings: listings.length, ...wantDetails(want) }, 'listings found'); log.info({ listings: listings.length, ...wantDetails(want) }, 'listings added');
await offers.generate(); events.emit(Events.ListingsUpdated);
await notifyUser(want.userId);
} }
log.info({ wants: wants.length }, 'finished syncing'); log.info({ wants: wants.length }, 'finished syncing');
} }
async function notifyUser(id: UUID) {
const rpc = connections.get(id);
if (!rpc) return null;
rpc.pushUserWants();
rpc.pushUserOffers();
};
events.on(Events.WantsAdded, async function wantsAdded(wants: Want[]) { events.on(Events.WantsAdded, async function wantsAdded(wants: Want[]) {
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population'); log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'starting listings population');
await updateWantListings(wants); await updateWantListings(wants);
log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings'); log.info({ event: Events.WantsAdded, wants: wants.map(w => wantDetails(w)) }, 'finished populating listings');
}); });
events.on(Events.Sync, async function sync() { events.on(Events.StartupSync, async function startupSync() {
try {
const wants = await pg.wantsToSync(); const wants = await pg.wantsToSync();
log.info({ event: Events.Sync, wants: wants.length }); log.info({ event: Events.StartupSync, wants: wants.length });
await updateWantListings(wants); await updateWantListings(wants);
events.emit(Events.ListingsUpdated);
if (wants.length === 0) {
log.info('all listings up to date');
await setTimeout(10000)
// forever
events.emit(Events.Sync);
}
} catch (e) {
log.error(e, 'sync error')
}
}); });
// events.on(Events.ListingsUpdated, async function listingsUpdated() { events.on(Events.ListingsUpdated, async function listingsUpdated() {
// const updatedOffers = await offers.generate() const updatedOffers = await offers.generate()
// log.debug({ offers: updatedOffers.length }, 'offers up to date'); log.debug({ offers: updatedOffers.length }, 'offers up to date');
// }); });
events.on(Events.Connection, async function onConnection({ rpc, id }) { events.on(Events.Connection, async function onConnection({ ws, id }) {
log.debug('new rpc connection', { id, rpc }); connections.set(id, ws);
connections.set(id, rpc);
}) })
events.on(Events.DiscordMessage, async function onDiscordMessage(msg) {
discord.message(msg);
})
// emitted by listen/notify
// disabled for now
// events.on(Events.OffersUpdated, async function onOffersUpdated(offer: Offer) {
// const rpc = connections.get(offer.userId);
// if (!rpc) return null;
// log.debug({ event: Events.OffersUpdated, rpc, offer });
// return rpc.pushUserOffers();
// });
// events.on(Events.WantsUpdated, async function onWantsUpdated(want: Want) {
// const rpc = connections.get(want.userId);
// if (!rpc) return null;
// log.debug({ event: Events.WantsUpdated, rpc, want });
// return rpc.pushUserWants();
// });
return events; return events;
} }
-60
View File
@@ -1,60 +0,0 @@
import createSubscriber from 'pg-listen'
import type Log from 'bunyan';
import { Events } from './events';
import { Pg } from './pg';
export default function createNotify(log: Log, pg: Pg, events) {
const OFFERS_CHANNEL = 'offers.events';
const USERS_CHANNEL = 'users.events';
const LISTINGS_CHANNEL = 'listings.events';
const WANTS_CHANNEL = 'wants.events';
const subscriber = createSubscriber({ connectionString: process.env.DATABASE_URL });
// pg has a hard limit of 8000B for the notification payload
// so we only receive the ids of the updated record
// and go fetch the whole thing to figure out who to notify
async function handleOffers(payload) {
const { action, id } = payload;
log.debug({ channel: OFFERS_CHANNEL, action, id });
if (action === 'DELETE') return null;
// const offer = await pg.offerFindId(id);
// events.emit(Events.OffersUpdated, offer);
}
async function handleUsers(payload) {
const { action, id } = payload;
log.debug({ channel: USERS_CHANNEL, action, id });
}
async function handleListings(payload) {
const { action, id } = payload;
log.debug({ channel: LISTINGS_CHANNEL, action, id });
}
async function handleWants(payload) {
const { action, id } = payload;
// const want = await pg.offerFindId(id);
log.debug({ channel: WANTS_CHANNEL, action, id });
}
subscriber.notifications.on(OFFERS_CHANNEL, handleOffers);
subscriber.notifications.on(USERS_CHANNEL, handleUsers);
subscriber.notifications.on(LISTINGS_CHANNEL, handleListings);
subscriber.notifications.on(WANTS_CHANNEL, handleWants);
async function listen() {
await subscriber.connect();
await Promise.all([
subscriber.listenTo(OFFERS_CHANNEL),
subscriber.listenTo(USERS_CHANNEL),
subscriber.listenTo(LISTINGS_CHANNEL),
subscriber.listenTo(WANTS_CHANNEL),
]);
}
return { listen };
}
+14 -17
View File
@@ -51,7 +51,8 @@ export default function createOffers(log: Log, pg: Pg) {
// TODO probably need to take one of each condition of record from a store // TODO probably need to take one of each condition of record from a store
// eg. they have a VG+ and a M we should make offers for each // eg. they have a VG+ and a M we should make offers for each
log.trace('generated offers', sorted); log.info(sorted);
const inserts = await pg.offersUpdate(sorted); const inserts = await pg.offersUpdate(sorted);
return inserts; return inserts;
@@ -63,20 +64,19 @@ export default function createOffers(log: Log, pg: Pg) {
// check the user actually has this offer // check the user actually has this offer
const offer = await pg.userOfferFind(ctx.state.user, id); const offer = await pg.userOfferFind(ctx.state.user, id);
return pg.offerStatusSet([offer.id], Status.Accepted); return pg.offerStatusSet(offer.id, Status.Accepted);
} }
async function statusSet(user: User, ids: UUID[], status: Status) { async function statusSet(user: User, id: UUID, status: Status) {
log.info({ status, ids }, 'setting offer status'); log.info({ status, id }, 'setting offer status');
// const offer = await pg.offerFindId(ids); const offer = await pg.offerFindId(id);
const update = await pg.offerStatusSet(ids, status); const update = await pg.offerStatusSet(offer.id, status);
// forget about this for now if (status === Status.Complete) {
// if (status === Status.Complete) { log.info({ id, status }, 'completed offer, removing want...');
// log.info({ id, status }, 'completed offer, removing want...'); await pg.wantRm(offer.wantId);
// await pg.wantRm(offer.wantId); }
// }
return update; return update;
} }
@@ -113,14 +113,12 @@ export default function createOffers(log: Log, pg: Pg) {
const offers = await pg.userOffers(user); const offers = await pg.userOffers(user);
const groups = Object.values(groupBy(offers, (o) => o.wantId)) const groups = Object.values(groupBy(offers, (o) => o.wantId))
.map(g => { .map(g => {
const offers = sortBy(g, sortOffers);
// the offer status is just the most advanced status // the offer status is just the most advanced status
// based on sort // based on sort
const offers = sortBy(g, sortOffers);
const status = offers[0].status; const status = offers[0].status;
// now actually sort them by price
offers.sort((a, b) => a.total - b.total);
const details = offers[0].want.data; const details = offers[0].want.data;
return { return {
@@ -136,8 +134,7 @@ export default function createOffers(log: Log, pg: Pg) {
async function samples() { async function samples() {
const user = await pg.userFindEmail('ntr@mnml.gg'); const user = await pg.userFindEmail('ntr@mnml.gg');
const offerGroups = await userOffers(user); const offerGroups = await userOffers(user);
const i = Math.floor(Math.random() * offerGroups.length / 2); return offerGroups.slice(0, 1);
return offerGroups[i];
} }
return { return {
+17 -48
View File
@@ -5,7 +5,6 @@ import * as Bluebird from 'bluebird';
import _ from 'lodash'; import _ from 'lodash';
import { Status } from './offers'; import { Status } from './offers';
import { wantDetails } from './utils';
export interface UserCreateParams { export interface UserCreateParams {
email: string, email: string,
@@ -22,12 +21,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
async function userWants(user: User) { async function userWants(user: User) {
const wants = await prisma.want.findMany({ return prisma.want.findMany({ where: { userId: user.id }});
where: { userId: user.id },
include: { _count: { select: { offers: true }} },
})
return _.sortBy(wants, w => `${w.data.artists_sort} - ${w.data.title}`);
} }
async function userOffers(user: User) { async function userOffers(user: User) {
@@ -40,7 +34,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
want: true, want: true,
}, },
orderBy: [ orderBy: [
{ updatedAt: 'asc' },
{ total: 'asc' }, { total: 'asc' },
{ listingId: 'asc' }, { listingId: 'asc' },
], ],
@@ -100,12 +93,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
}); });
} }
async function wantFindId(id: UUID) {
return prisma.want.findFirstOrThrow({
where: { id },
});
}
async function wantList(user: User) { async function wantList(user: User) {
return prisma.want.findMany({ return prisma.want.findMany({
where: { userId: user.id }, where: { userId: user.id },
@@ -113,12 +100,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
async function wantsToSync() { async function wantsToSync() {
return prisma.want.findMany({ return prisma.want.findMany({});
orderBy: { updatedAt: 'asc' },
where: {
updatedAt: { lte: new Date(Date.now() - 1000 * 60 * 15) }
}
});
} }
async function wantRm(id: UUID) { async function wantRm(id: UUID) {
@@ -139,21 +121,14 @@ export default function createPg(log: Log, prisma: PrismaClient) {
}); });
} }
async function wantSynced(want: Want) {
return prisma.want.update({
where: { id: want.id },
data: { updatedAt: new Date() },
});
}
async function listingRmUnlisted(want: Want, currentListingIds: DiscogsListing[]) { async function listingRmUnlisted(want: Want, currentListingIds: DiscogsListing[]) {
return prisma.$transaction(async (prisma) => { return prisma.$transaction(async (prisma) => {
log.debug({ currentListingIds }); log.debug({ currentListingIds });
const protectedOfferStatuses = [Status.Confirmed, Status.Complete]; const protectedOfferStatuses = [Status.Confirmed, Status.Complete];
// delete all the offers where we don't want to keep them // can't delete a listing with an open offer
// for historical reasons // so delete those offers first
const { count: offersRemoved } = await prisma.offer.deleteMany({ const { count: offersRemoved } = await prisma.offer.deleteMany({
where: { where: {
wantId: want.id, wantId: want.id,
@@ -166,7 +141,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
} }
}); });
if (offersRemoved > 0) log.info({ offersRemoved, ...wantDetails(want) }, 'pending offers removed'); if (offersRemoved > 0) log.info({ offersRemoved }, 'open offers removed');
// now we have to filter listingIds with confirmed offers // now we have to filter listingIds with confirmed offers
// and get their listingIds to filter out // and get their listingIds to filter out
@@ -175,26 +150,25 @@ export default function createPg(log: Log, prisma: PrismaClient) {
wantId: want.id, wantId: want.id,
status: { status: {
in: protectedOfferStatuses, in: protectedOfferStatuses,
}, }
}, },
select: { select: {
listingId: true, listingId: true,
}, }
})).map(r => r.listingId); })).map(r => r.listingId);
const removalIds = currentListingIds.filter(id => confirmedListingIds.includes(id)); const removalIds = _.without(currentListingIds, ...confirmedListingIds);
if (removalIds.length) log.info({ removalIds });
// finally we delete listings that aren't related // finally we delete listings that aren't related
// to a historical offer // to a historical offer
const { count } = await prisma.listing.deleteMany({ const { count } = await prisma.listing.deleteMany({
where: { where: {
id: { in: removalIds }, wantId: want.id,
}, id: {
notIn: currentListingIds,
}
}
}); });
if (count) log.info({ count }, 'second stage open offers removed');
return count; return count;
}); });
} }
@@ -247,7 +221,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
// but for ease of abstraction we get the listings themselves // but for ease of abstraction we get the listings themselves
// grouped by seller // grouped by seller
const candidates = await prisma.$queryRaw` const candidates = await prisma.$queryRaw`
WITH groups AS (
select select
seller, count(distinct("wantId")), array_agg(id) as listings seller, count(distinct("wantId")), array_agg(id) as listings
from from
@@ -258,10 +231,7 @@ export default function createPg(log: Log, prisma: PrismaClient) {
seller seller
order by order by
count desc count desc
) limit 5;
select *
from groups
where count >= 5;
` as BundleCandidate[]; ` as BundleCandidate[];
return Promise.all(candidates.map(async c => prisma.listing.findMany({ return Promise.all(candidates.map(async c => prisma.listing.findMany({
@@ -289,9 +259,9 @@ export default function createPg(log: Log, prisma: PrismaClient) {
); );
} }
async function offerStatusSet(ids: UUID[], status: Status) { async function offerStatusSet(id: UUID, status: Status) {
return prisma.offer.updateMany({ return prisma.offer.update({
where: { id: { in: ids } }, where: { id: id },
data: { status }, data: { status },
}); });
} }
@@ -318,7 +288,6 @@ export default function createPg(log: Log, prisma: PrismaClient) {
wantCreate, wantCreate,
wantList, wantList,
wantsToSync, wantsToSync,
wantSynced,
wantRm, wantRm,
listingUpsert, listingUpsert,
+1 -3
View File
@@ -4,9 +4,8 @@ import { Strategy as LocalStrategy } from 'passport-local';
import Router from '@koa/router' import Router from '@koa/router'
import type { Pg } from './../pg'; import type { Pg } from './../pg';
import { Events } from './../events';
export default function authRouter(log, pg: Pg, events) { export default function authRouter(log, pg: Pg) {
const passport = new KoaPassport(); const passport = new KoaPassport();
async function verifyPassword(user, password) { async function verifyPassword(user, password) {
@@ -70,7 +69,6 @@ export default function authRouter(log, pg: Pg, events) {
id: user.id, id: user.id,
email: user.email, email: user.email,
}; };
events.emit(Events.DiscordMessage, { msg: 'user register', email: user.email });
}); });
return { authRouter, passport }; return { authRouter, passport };
+77 -125
View File
@@ -1,3 +1,5 @@
import { EventEmitter } from 'node:events';
import { setTimeout } from 'timers/promises';
import assert from 'assert/strict'; import assert from 'assert/strict';
import type Log from 'bunyan'; import type Log from 'bunyan';
@@ -10,82 +12,6 @@ import type { Offers } from './offers';
import { Status } from './offers'; import { Status } from './offers';
import { isAdmin } from './utils'; import { isAdmin } from './utils';
import { Events } from './events';
export type Rpc = ReturnType<typeof createRpc>;
export enum RpcMessageType {
Ping = 'Ping',
Pong = 'Pong',
// points out user is anon
WhoDis = 'WhoDis',
Bundles = 'Bundles',
Offers = 'Offers',
List = 'List',
Wants = 'Wants',
User = 'User',
Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers',
AcpListingsSync = 'AcpListingsSync',
WantRm = 'WantRm',
WantAdd = 'WantAdd',
}
type ErrorMessage = [RpcMessageType.Error, string];
type PongMessage = [RpcMessageType.Pong, []];
type PingMessage = [RpcMessageType.Ping, []];
type WhoDisMessage = [RpcMessageType.WhoDis, []];
type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
type AcpListingsSyncMessage = [RpcMessageType.AcpListingsSync, []];
export type RpcMessage =
PingMessage |
PongMessage |
WhoDisMessage |
UserMessage |
WantsMessage |
OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage |
AcpOfferStatusSetMessage |
AcpOffersMessage |
AcpListingsSyncMessage |
ErrorMessage;
type OfferStatusSetParams = {
ids: UUID[],
status: Status,
};
type WantRmParams = {
id: UUID,
};
type WantAddParams = {
releaseId: number,
};
function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType { function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
if (RpcMessageType[msgType]) { if (RpcMessageType[msgType]) {
@@ -95,7 +21,7 @@ function checkMsgType(msgType: RpcMessageType): msgType is RpcMessageType {
} }
// rpc is created per connection from the api // rpc is created per connection from the api
export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, events, user: User | null) { export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers: Offers, user: User | null) {
async function ping(): Promise<PongMessage> { async function ping(): Promise<PongMessage> {
return [RpcMessageType.Pong, []]; return [RpcMessageType.Pong, []];
} }
@@ -109,15 +35,13 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
} }
async function offerStatusSet(params: OfferStatusSetParams): Promise<OffersMessage> { async function offerStatusSet(params: OfferStatusSetParams): Promise<OffersMessage> {
const { ids, status } = params; const { id, status } = params;
assert(ids, 'no ids supplied'); assert(id, 'no id supplied');
assert(Status[status], 'unknown status'); assert(Status[status], 'unknown status');
const offer = await offers.statusSet(user, ids, status); const offer = await offers.statusSet(user, id, status);
const userOffers = await offers.userOffers(user); const userOffers = await offers.userOffers(user);
events.emit(Events.DiscordMessage, { msg: 'user offer event', user: user.email, offer })
return [RpcMessageType.Offers, userOffers] return [RpcMessageType.Offers, userOffers]
} }
@@ -131,18 +55,6 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
return [RpcMessageType.Wants, wants]; return [RpcMessageType.Wants, wants];
} }
async function wantAdd(params: WantAddParams): Promise<WantsMessage> {
const { releaseId } = params;
assert(releaseId, 'no id supplied');
const release = await discogs.release(releaseId);
const want = await pg.wantCreate(release, user);
events.emit(Events.WantsAdded, [want]);
const wants = await pg.userWants(user);
return [RpcMessageType.Wants, wants];
}
async function acpOfferStatusSet(params: OfferStatusSetParams): Promise<AcpOffersMessage> { async function acpOfferStatusSet(params: OfferStatusSetParams): Promise<AcpOffersMessage> {
const { id, status } = params; const { id, status } = params;
assert(isAdmin(user), 'not authorised'); assert(isAdmin(user), 'not authorised');
@@ -159,12 +71,6 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
return [RpcMessageType.AcpOffers, await offers.acpOffers()]; return [RpcMessageType.AcpOffers, await offers.acpOffers()];
} }
async function acpListingsSync(): Promise<AcpListingsSyncMessage> {
assert(isAdmin(user), 'not authorised');
events.emit(Events.Sync);
return [RpcMessageType.AcpListingsSync, []];
}
async function receive(message: any): Promise<RpcMessage> { async function receive(message: any): Promise<RpcMessage> {
const [msgType, params] = message; const [msgType, params] = message;
@@ -190,15 +96,11 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
case RpcMessageType.WantRm: case RpcMessageType.WantRm:
return wantRm(params); return wantRm(params);
case RpcMessageType.WantAdd:
return wantAdd(params);
case RpcMessageType.AcpOfferStatusSet: case RpcMessageType.AcpOfferStatusSet:
return acpOfferStatusSet(params); return acpOfferStatusSet(params);
case RpcMessageType.AcpOffers: case RpcMessageType.AcpOffers:
return acpOffers(); return acpOffers();
case RpcMessageType.AcpListingsSync:
return acpListingsSync();
default: default:
throw new Error(`Message type ${msgType} is known but unhandled`); throw new Error(`Message type ${msgType} is known but unhandled`);
@@ -211,12 +113,9 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
} }
async function anonConnection() { async function anonConnection() {
const samples = await offers.samples();
} const sampleMsg: OfferSamplesMessage = [RpcMessageType.OfferSamples, samples];
send(sampleMsg);
async function adminConnection() {
// don't need it, requested on route
// send(await acpOffers());
} }
async function onConnection() { async function onConnection() {
@@ -225,10 +124,9 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
const userMsg: UserMessage = [RpcMessageType.User, user]; const userMsg: UserMessage = [RpcMessageType.User, user];
send(userMsg); send(userMsg);
await pushUserOffers(); const wants = await pg.userWants(user);
await pushUserWants(); const wantsMsg: WantsMessage = [RpcMessageType.Wants, wants];
send(wantsMsg);
if (isAdmin(user)) return adminConnection();
} }
function send(msg: RpcMessage) { function send(msg: RpcMessage) {
@@ -239,20 +137,74 @@ export default function createRpc(log: Log, pg: Pg, ws, discogs: Discogs, offers
)) ))
} }
async function pushUserOffers() {
send(await userOffers());
}
async function pushUserWants() {
send(await userWants());
}
return { return {
receive, receive,
send, send,
onConnection, onConnection,
}
}
pushUserOffers, export enum RpcMessageType {
pushUserWants, Ping = 'Ping',
} Pong = 'Pong',
// points out user is anon
WhoDis = 'WhoDis',
Bundles = 'Bundles',
Offers = 'Offers',
List = 'List',
Wants = 'Wants',
User = 'User',
Error = 'Error',
OfferSamples = 'OfferSamples',
OfferStatusSet = 'OfferStatusSet',
AcpOfferStatusSet = 'AcpOfferStatusSet',
AcpOffers = 'AcpOffers',
WantRm = 'WantRm',
} }
type ErrorMessage = [RpcMessageType.Error, string];
type PongMessage = [RpcMessageType.Pong, []];
type PingMessage = [RpcMessageType.Ping, []];
type WhoDisMessage = [RpcMessageType.WhoDis, []];
type UserMessage = [RpcMessageType.User, User];
type WantsMessage = [RpcMessageType.Wants, Want[]];
type OffersMessage = [RpcMessageType.Offers, Offer[]];
type OfferStatusSetMessage = [RpcMessageType.OfferStatusSet, Offer[]];
type OfferSamplesMessage = [RpcMessageType.OfferSamples, Offer[]];
type AcpOfferStatusSetMessage = [RpcMessageType.AcpOfferStatusSet, Offer[]];
type AcpOffersMessage = [RpcMessageType.AcpOffers, Offer[]];
export type RpcMessage =
PingMessage |
PongMessage |
WhoDisMessage |
UserMessage |
WantsMessage |
OffersMessage |
OfferSamplesMessage |
OfferStatusSetMessage |
AcpOfferStatusSetMessage |
AcpOffersMessage |
ErrorMessage;
type OfferStatusSetParams = {
id: UUID,
status: Status,
};
type WantRmParams = {
id: UUID,
};
+6 -12
View File
@@ -1,30 +1,24 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import createApi from './api';
import createDiscogs from './discogs'; import createDiscogs from './discogs';
import createEvents from './events';
import createLog from './log';
import createNotify from './notify';
import createOffers from './offers'; import createOffers from './offers';
import createLog from './log';
import createApi from './api';
import createEvents from './events';
import createPg from './pg'; import createPg from './pg';
import createDiscord from './discord';
export default async function createServices(log) { export default function creates(log) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const discord = createDiscord(log);
const pg = createPg(log, prisma); const pg = createPg(log, prisma);
const discogs = await createDiscogs(log, pg); const discogs = createDiscogs(log, pg);
const offers = createOffers(log, pg); const offers = createOffers(log, pg);
const events = createEvents(log, pg, discogs, offers, discord); const events = createEvents(log, pg, discogs, offers);
const notify = createNotify(log, pg, events);
const api = createApi(log, pg, discogs, offers, events); const api = createApi(log, pg, discogs, offers, events);
return { return {
api, api,
discogs, discogs,
discord,
events, events,
notify,
pg, pg,
prisma, prisma,
}; };
-2
View File
@@ -17,8 +17,6 @@ export function logAxiosError(e) {
export function wantDetails(want: Want) { export function wantDetails(want: Want) {
const data = want.data as any; const data = want.data as any;
if (!data) console.warn(want);
return { return {
title: data.title, title: data.title,
release: data.id, release: data.id,
+7 -11
View File
@@ -8,27 +8,23 @@ module.exports = {
"build/" "build/"
], ],
rules: { rules: {
'arrow-body-style': [0],
'max-len': ['error', 120],
'no-console': [0],
'no-multi-spaces': [0],
// prevents stupid complaints a la // prevents stupid complaints a la
// (req) { // (req) {
// req.something = x; // req.something = x;
// } // }
'no-param-reassign': [2, { props: false }], 'no-param-reassign': [2, { props: false }],
'no-return-assign': 0, 'no-multi-spaces': [0],
'max-len': ['error', 120],
'import/no-extraneous-dependencies': [0],
'prefer-arrow-callback': [0], 'prefer-arrow-callback': [0],
'arrow-body-style': [0],
'no-return-assign': 0,
'no-console': [0],
// i like loops // i like loops
'no-plusplus': [0], 'no-plusplus': [0],
'no-await-in-loop': [0], 'no-await-in-loop': [0],
indent: ['error', 4], indent: ['error', 4],
'keyword-spacing': ['error'], 'keyword-spacing': ['error'],
'key-spacing': ['error'], 'key-spacing': ['error'],
}, },
-12
View File
@@ -1,12 +0,0 @@
# build step
FROM node:16 AS build-env
WORKDIR /opt/crates/client
COPY . ./
RUN npm run build
# static server
FROM nginx
WORKDIR /srv/app
COPY --from=build-env /opt/crates/client/build /srv/app
COPY ./client.nginx.conf /etc/nginx/nginx.conf
-8
View File
@@ -1,8 +0,0 @@
# build step
FROM node:16 AS build-env
WORKDIR /opt/crates/client
COPY . ./
RUN npm i
CMD ["npm", "start"]
-49
View File
@@ -1,49 +0,0 @@
events {
worker_connections 1024;
}
http {
include mime.types;
sendfile on;
server {
listen 8080;
listen [::]:8080;
autoindex off;
root /srv/app;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/x-javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/xhtml+xml
application/xml
font/eot
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
error_page 404 /;
}
}
Generated Executable → Regular
-25
View File
@@ -10,7 +10,6 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.5", "@reduxjs/toolkit": "^1.8.5",
"izitoast": "^1.4.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"logrocket": "^3.0.1", "logrocket": "^3.0.1",
"preact": "^10.10.0", "preact": "^10.10.0",
@@ -22,7 +21,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-react-jsx-source": "^7.18.6", "@babel/plugin-transform-react-jsx-source": "^7.18.6",
"@types/lodash": "^4.17.10",
"@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6", "@typescript-eslint/parser": "^5.30.6",
"eslint": "^8.20.0", "eslint": "^8.20.0",
@@ -3010,13 +3008,6 @@
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true "dev": true
}, },
"node_modules/@types/lodash": {
"version": "4.17.10",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz",
"integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz",
@@ -10853,11 +10844,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/izitoast": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
},
"node_modules/jake": { "node_modules/jake": {
"version": "10.8.5", "version": "10.8.5",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
@@ -23445,12 +23431,6 @@
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true "dev": true
}, },
"@types/lodash": {
"version": "4.17.10",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz",
"integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==",
"dev": true
},
"@types/mime": { "@types/mime": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.0.tgz",
@@ -29586,11 +29566,6 @@
"istanbul-lib-report": "^3.0.0" "istanbul-lib-report": "^3.0.0"
} }
}, },
"izitoast": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz",
"integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w=="
},
"jake": { "jake": {
"version": "10.8.5", "version": "10.8.5",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
Executable → Regular
+1 -3
View File
@@ -6,11 +6,10 @@
"scripts": { "scripts": {
"build": "preact build --no-prerender", "build": "preact build --no-prerender",
"start": "preact --no-clear watch", "start": "preact --no-clear watch",
"lint": "eslint --fix src" "lint": "eslint src"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.5", "@reduxjs/toolkit": "^1.8.5",
"izitoast": "^1.4.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"logrocket": "^3.0.1", "logrocket": "^3.0.1",
"preact": "^10.10.0", "preact": "^10.10.0",
@@ -22,7 +21,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-react-jsx-source": "^7.18.6", "@babel/plugin-transform-react-jsx-source": "^7.18.6",
"@types/lodash": "^4.17.10",
"@typescript-eslint/eslint-plugin": "^5.30.6", "@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6", "@typescript-eslint/parser": "^5.30.6",
"eslint": "^8.20.0", "eslint": "^8.20.0",
+8 -18
View File
@@ -9,24 +9,16 @@ import store, { wsState } from './store'
import Nav from './components/nav'; import Nav from './components/nav';
import Main from './routes/main'; import Offers from './routes/offers';
import Acp from './routes/acp'; import Acp 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';
import Info from './routes/info'; import Info from './routes/info';
if (window.location.host === 'crates.ntwl.xyz') LogRocket.init('yh0dy3/mcrates'); if (window.location.host === 'matescrates.au') LogRocket.init('yh0dy3/mcrates');
const Main = () => {
// ██ ██ ███████ █████ ██████ ███████ ███████ ██████ ██████ █████ ██████ ██ ██
// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
// ██ █ ██ █████ ███████ ██████ █████ ███████ ██ ██ ██████ ███████ ██ █████
// ██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
// ███ ███ ███████ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ██ ██ ██████ ██ ██
const Mcrates = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
// i have no idea why this is needed, // i have no idea why this is needed,
@@ -41,32 +33,30 @@ const Mcrates = () => {
const { ws } = store.getState(); const { ws } = store.getState();
if (!ws) return null; if (!ws) return null;
if (e.url === '/acp') return ws.sendAcpOffers(); if (e.url === '/acp') return ws.sendAcpOffers();
if (e.url === '/') return ws.sendOffers();
if (e.url === '/list') return ws.sendWants();
} }
return ( return (
<div id="mcrates"> <main id="app">
<Nav /> <Nav />
<Router onChange={routeChange}> <Router onChange={routeChange}>
<Route path="/" component={Main} /> <Route path="/" component={Offers} />
<Route path="/login" component={Login} mode="login" /> <Route path="/login" component={Login} mode="login" />
<Route path="/register" component={Login} mode="register" /> <Route path="/register" component={Login} mode="register" />
<Route path="/search" component={Search} /> <Route path="/search" component={Search} />
<Route path="/list" component={List} /> <Route path="/list" component={List} />
{/*<Route path="/info" component={Info} />*/} <Route path="/info" component={Info} />
{/* ACP */} {/* ACP */}
<Route path="/acp" component={Acp} /> <Route path="/acp" component={Acp} />
</Router> </Router>
</div> </main>
); );
}; };
const App = () => { const App = () => {
return ( return (
<Provider store={store}> <Provider store={store}>
<Mcrates /> <Main />
</Provider> </Provider>
); );
} }
+2 -2
View File
@@ -25,7 +25,7 @@ const Nav = () => {
} }
route(DEFAULT_ROUTE) route(DEFAULT_ROUTE)
if (window.location.host === 'crates.ntwl.xyz') { if (window.location.host === 'matescrates.au') {
LogRocket.identify(user.id, { LogRocket.identify(user.id, {
name: user.email, name: user.email,
email: user.email, email: user.email,
@@ -72,7 +72,7 @@ const Nav = () => {
return ( return (
<header> <header>
{user ? nav() : anonNav()} {user ? nav() : anonNav()}
{showAdmin(user) ? adminNav() : <div />} {showAdmin(user) ? adminNav() : <div></div>}
{user ? userNav() : null} {user ? userNav() : null}
</header> </header>
); );
+7 -81
View File
@@ -1,7 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import { ExternalLink, Check } from 'react-feather'; import { ExternalLink, Check } from 'react-feather';
import { useState, useCallback } from 'preact/hooks'; import { useState } from 'preact/hooks';
import _ from 'lodash'
import { useSelector, useDispatch } from 'react-redux' import { useSelector, useDispatch } from 'react-redux'
import { State } from './../store'; import { State } from './../store';
@@ -50,64 +49,8 @@ function OfferPriceBtn(offer) {
? offer.status ? offer.status
: 'Accept'; : 'Accept';
const title = accepted
? 'Click to cancel'
: 'Click to accept this offer';
return ( return (
<button disabled={confirmed} title={title} onClick={() => setStatus()} class={classes}>${offer.total} {offer.condition}</button> <button disabled={confirmed} onClick={() => setStatus()} class={classes}>${offer.total} {offer.condition}</button>
)
}
function OfferConditionSlider([condition, offers], i, groupId, rangeMin, rangeMax) {
const step = 'any'
const id = `${groupId}-${i}`
const markersId = `${id}-markers`
const steps = offers.map(o => o.total)
const initialValue = _.last(offers.filter(o => o.status === 'Accepted'))?.total || 0
const ws = useSelector((state: State) => state.ws);
const [acceptedPrice, setAcceptedPrice] = useState(initialValue)
const sendOffers = useCallback(_.debounce(function send(v) {
const accepted = offers.filter(o => Number(o.total) <= v).map(o => o.id)
const pending = offers.filter(o => Number(o.total) > v).map(o => o.id)
ws.sendOfferStatusSet(accepted, 'Accepted')
ws.sendOfferStatusSet(pending, 'Pending')
}, 1000), [])
function onChange(target) {
const highest = () => {
return _.findLast(steps, s => target.value >= Number(s)) || 0
}
const v = highest()
target.value = v
setAcceptedPrice(v)
sendOffers(v)
}
const acceptedPriceLabel = acceptedPrice ? `$${acceptedPrice}` : '-'
return (
<div key={id}>
<label for={id}>{condition}</label><br />
<label for={id}>{acceptedPriceLabel}</label><br />
<input
type="range"
id={id}
name={id}
list={markersId}
step={step}
min={rangeMin}
max={rangeMax}
value={acceptedPrice}
onChange={e => onChange((e.target as HTMLInputElement)) } />
<datalist id={markersId}>
{offers.map(o => <option value={o.total}></option>)}
</datalist>
</div>
) )
} }
@@ -115,36 +58,19 @@ function OfferConditionSlider([condition, offers], i, groupId, rangeMin, rangeMa
export default function OfferGroup(group, i) { export default function OfferGroup(group, i) {
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
const format = group.details.formats[0].text ? `${group.details.formats[0].text} - ` : '';
const descriptions = group.details.formats.map(f => f.descriptions.join(' ')).join(' ')
const detailText = `${format} ${descriptions}`;
const conditions = Object.entries(_.groupBy(group.offers, 'condition'))
const rangeMin = 0
const rangeMax = _.max(group.offers.map(o => Number(o.total)))
const SHOW_BUTTONS = false
const buttons = SHOW_BUTTONS ? (
<div class="lines">
{group.offers.map(o => OfferPriceBtn(o))}
</div>
) : null
return ( return (
<div class="offer" key={i} id={group.details.id}> <div class="offer" key={i}>
<section> <section>
<h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3> <h3>{group.details.artists_sort} - {group.details.title} <a href={group.details.uri} target="_blank"><ExternalLink /></a></h3>
<div class='alt-text details'> <div class='alt-text details'>
{group.details.year} - {detailText} {group.details.year} - {group.details.formats.map(f => f.descriptions.join(' ')).join(' ')}
</div> </div>
<div class='alt-text details'>Status: {group.status}</div> <div class='alt-text details'>Status: {group.status}</div>
</section> </section>
<img src={group.details.thumb} /> <img src={group.details.thumb} />
<div> <div class="lines">
{conditions.map((c, i) => OfferConditionSlider(c, i, group.details.id, rangeMin, rangeMax))} {group.offers.map(o => OfferPriceBtn(o))}
</div> </div>
{ buttons }
</div> </div>
) )
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export default function Offer(offer) {
return ( return (
<div class="offer"> <div class="offer">
<section> <section>
<h3>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3> <h3>{details.artists_sort} - {details.title} <a href={details.uri} target="_blank"><ExternalLink /></a></h3>
<div class='alt-text' >{details.year}</div> <div class='alt-text' >{details.year}</div>
<div class='alt-text'>{details.formats[0].descriptions.join(' ')}</div> <div class='alt-text'>{details.formats[0].descriptions.join(' ')}</div>
<div class='number'>mcrates price: ${offer.total}</div> <div class='number'>mcrates price: ${offer.total}</div>
+8 -9
View File
@@ -1,10 +1,12 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState, useCallback } from 'preact/hooks';
import { ExternalLink } from 'react-feather'; import { ExternalLink } from 'react-feather';
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { State } from './../store' import { State } from './../store'
import http from './../http';
function Want(want) { function Want(want) {
const r = want.data; const r = want.data;
@@ -12,13 +14,10 @@ function Want(want) {
const [confirm, setConfirm] = useState(false); const [confirm, setConfirm] = useState(false);
function onClick() { function onClick() {
if (confirm) { if (confirm) return ws.sendWantRm(want.id);
setConfirm(false);
return ws.sendWantRm(want.id);
}
setTimeout(() => setConfirm(false), 2500); setTimeout(() => setConfirm(false), 2500);
return setConfirm(true); return setConfirm(true);
} };
const text = confirm const text = confirm
? 'confirm' ? 'confirm'
@@ -27,12 +26,12 @@ function Want(want) {
return ( return (
<article key={r.id}> <article key={r.id}>
<div> <div>
<h3>{r.artists_sort} - {r.title} <a href={r.uri} target="_blank" rel="noreferrer"><ExternalLink /></a></h3> <h3>{r.artists_sort} - {r.title} <a href={r.uri} target="_blank"><ExternalLink /></a></h3>
</div> </div>
<img src={r.thumb} /> <img src={r.thumb} />
<div class='details'> <div class='details'>
<div class='alt'>{r.year} - {r.formats[0].descriptions.join(' ')}</div> <div class='alt'>{r.year}</div>
<div class='alt'>{want._count.offers} offers</div> <div class='alt'>{r.formats[0].descriptions.join(' ')}</div>
</div> </div>
<button onClick={onClick} >{text}</button> <button onClick={onClick} >{text}</button>
</article> </article>
+28 -4
View File
@@ -32,18 +32,34 @@ function onError(error) {
api.interceptors.response.use(onSuccess, onError); api.interceptors.response.use(onSuccess, onError);
const actions = { const actions = {
// TODO move to rpc getBundles: async function getBundles() {
return api.get('/acp/bundles');
},
getOffers: async function getOffers() {
return api.get('/user/offers');
},
getOffersAcp: async function getOffersAcp() {
return api.get('/acp/offers');
},
getWants: async function getWants() {
return api.get('/want/all');
},
postWantSearch: async function postWantSearch(query) { postWantSearch: async function postWantSearch(query) {
return api.post('/want/search', { query }); return api.post('/want/search', { query });
}, },
postWant: async function postWant(releaseId) {
return api.post('/want', { releaseId });
},
postDiscogs: async function postDiscogs(username) { postDiscogs: async function postDiscogs(username) {
return api.post('/want/discogs', { username }); return api.post('/want/discogs', { username });
}, },
// --------------------------------
// require as http to set cookies
getLogin: async function getLogin() { getLogin: async function getLogin() {
return api.get('/auth/login') return api.get('/auth/login')
}, },
@@ -66,6 +82,14 @@ const actions = {
password, password,
}); });
}, },
postOffers: async function postOffers(listingIds, shipping, baseShipping) {
return api.post('/offers', {
listingIds,
shipping,
baseShipping,
});
},
}; };
export default actions; export default actions;
-2
View File
@@ -1,6 +1,4 @@
import 'izitoast/dist/css/iziToast.min.css';
import './style/index.less'; import './style/index.less';
import App from './app'; import App from './app';
export default App; export default App;
+12 -9
View File
@@ -34,11 +34,11 @@ function Item({ item }) {
return ( return (
<div key={item.id} class={bold}> <div key={item.id} class={bold}>
<a href={item.listing.data.uri} target="_blank" rel="noreferrer" > <a href={item.listing.data.uri} target="_blank" >
<h4>{item.listing.data.release.description}</h4> <h4>{item.listing.data.release.description}</h4>
</a> </a>
<h5>{item.user.email}</h5> <h5>{item.user.email}</h5>
<h5 class='alt-text'>{item.total} - {item.condition}</h5> <h5 class='alt-text'>{item.total}</h5>
<h5>{item.status}</h5> <h5>{item.status}</h5>
<label>Set Status:</label> <label>Set Status:</label>
<select onChange={onChange}> <select onChange={onChange}>
@@ -70,19 +70,22 @@ function Home() {
const offers = useSelector((state: State) => state.acpOffers); const offers = useSelector((state: State) => state.acpOffers);
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
function sync() { // useEffect(() => {
ws.sendAcpListingsSync(); // ws.sendAcpOffers();
// }, [offers, ws]);
if (!offers.length) {
return (
<div class={'offers'}>
loading...
</div>
);
} }
return ( return (
<div class='acp'>
<button onClick={sync}> sync listings </button>
<button onClick={sync}> generate offers </button>
<hr />
<div class='bundles'> <div class='bundles'>
{offers.map(SellerGroup)} {offers.map(SellerGroup)}
</div> </div>
</div>
); );
} }
-1
View File
@@ -18,7 +18,6 @@ const Info = () => {
<h2>How It Works</h2> <h2>How It Works</h2>
<p> First, add some records to your <span class='alt'>List</span> on the <a href='/search'>Search Page</a>. </p> <p> First, add some records to your <span class='alt'>List</span> on the <a href='/search'>Search Page</a>. </p>
<p> Mates Crates will then make <span class='alt'>Offers</span> to you for releases you add to your <a href='/list'>List</a>. </p> <p> Mates Crates will then make <span class='alt'>Offers</span> to you for releases you add to your <a href='/list'>List</a>. </p>
<p> (this can take a few minutes time!) </p>
<p> Select all <span class='alt'>Offers</span> you are interested in, the more you accept the more likely the item will be <span class='alt-text'>Confirmed</span>.</p> <p> Select all <span class='alt'>Offers</span> you are interested in, the more you accept the more likely the item will be <span class='alt-text'>Confirmed</span>.</p>
<p> Check back regularly for updated <span class='alt'>Offers</span>, the global record marketplace is a living thing. </p> <p> Check back regularly for updated <span class='alt'>Offers</span>, the global record marketplace is a living thing. </p>
</section> </section>
+7 -3
View File
@@ -4,11 +4,16 @@ import { useSelector } from 'react-redux'
import { route } from 'preact-router'; import { route } from 'preact-router';
import { State } from './../store' import { State } from './../store'
import * as toast from './../toast';
import Want from './../components/want'; import Want from './../components/want';
const List = () => { interface Props {
user: any,
wants: any[],
getUserOffers: (userId: string) => void,
}
const List = ({ user }: Props) => {
const wants = useSelector((state: State) => state.wants); const wants = useSelector((state: State) => state.wants);
if (!wants) { if (!wants) {
@@ -16,7 +21,6 @@ const List = () => {
} }
if (!wants.length) { if (!wants.length) {
toast.info('List Empty', 'You have no items in your list, add some with search.');
route('/search'); route('/search');
} }
+26 -27
View File
@@ -6,19 +6,19 @@ import { useSelector, useDispatch } from 'react-redux'
import http from './../http'; import http from './../http';
import { State, userState } from './../store'; import { State, userState } from './../store';
import Swirl from './../components/swirl'; import Swirl from './../components/swirl';
import OfferGroup from './../components/offer.group';
const Login = ({ mode }) => { const Login = ({ mode }) => {
const dispatch = useDispatch() const dispatch = useDispatch()
const user = useSelector((state: State) => state.user); const user = useSelector((state: State) => state.user);
const ws = useSelector((state: State) => state.ws); const ws = useSelector((state: State) => state.ws);
const [email, setEmail] = useState('')
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
function gotoRegister() { async function postLogin() {
return route('/register');
}
async function postLogin(email, password) {
const [error, user] = await http.postLogin(email, password); const [error, user] = await http.postLogin(email, password);
if (error) return setError(error); if (error) return setError(error);
route('/'); route('/');
@@ -27,23 +27,17 @@ const Login = ({ mode }) => {
ws.connect(); ws.connect();
} }
async function postRegister(email, password, confirm) { async function postRegister() {
if (password !== confirm) return setError('passwords do not match');
const [error, user] = await http.postRegister(email, password); const [error, user] = await http.postRegister(email, password);
if (error) return setError(error); if (error) return setError(error);
route('/search'); route('/search');
dispatch(userState.actions.set(user)); dispatch(userState.actions.set(user));
// reconnect ws to get logged in methods
ws.connect();
} }
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
if (mode === 'login') return postLogin()
console.log(e.target.email, e.target.password, e.target.confirm); if (mode === 'register') return postRegister();
if (mode === 'login') return postLogin(e.target.email.value, e.target.password.value);
if (mode === 'register') return postRegister(e.target.email.value, e.target.password.value, e.target.confirm.value);
} }
const emailRef = useRef<HTMLInputElement>() const emailRef = useRef<HTMLInputElement>()
@@ -59,21 +53,21 @@ const Login = ({ mode }) => {
if (mode === 'register') return ( if (mode === 'register') return (
<div class="login"> <div class="login">
{errorEl} {errorEl}
<form action="" onSubmit={handleSubmit}> <form action="">
<div class="item"> <div class="item">
<label for="email">email</label> <label for="email">email</label>
<input type="email" name="email" id="email" ref={emailRef} required /> <input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="item"> <div class="item">
<label for="password">password</label> <label for="password">password</label>
<input type="password" name="password" id="password" required /> <input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="item"> <div class="item">
<label for="password">confirm password</label> <label for="password">confirm password</label>
<input type="password" name="confirm" id="confirm" required /> <input type="password" name="password" id="password" value={confirmPassword} required onInput={(e) => setConfirmPassword((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="item"> <div class="item">
<input type="submit" value="register" /> <input type="submit" value="register" onClick={handleSubmit} />
</div> </div>
</form> </form>
</div> </div>
@@ -83,33 +77,38 @@ const Login = ({ mode }) => {
return ( return (
<div class="login"> <div class="login">
{errorEl} {errorEl}
<form action="" onSubmit={handleSubmit}> <form action="">
<div class="item"> <div class="item">
<label for="email">email</label> <label for="email">email</label>
<input type="email" name="email" id="email" ref={emailRef} /> <input type="email" name="email" id="email" ref={emailRef} value={email} required onInput={(e) => setEmail((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="item"> <div class="item">
<label for="password">password</label> <label for="password">password</label>
<input type="password" name="password" id="password" /> <input type="password" name="password" id="password" value={password} required onInput={(e) => setPassword((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="item"> <div class="item">
<input type="submit" value="login" /> <input type="submit" value="login" onClick={handleSubmit} />
</div> </div>
</form> </form>
<button onClick={gotoRegister}> create account </button>
</div> </div>
); );
}; };
function LoginPage({ mode }) { function LoginPage({ mode }) {
const offers = useSelector((state: State) => state.offers);
return ( return (
<div class="landing"> <div class="landing">
<Login mode={mode} /> <Login mode={mode} />
<div class='offers'>
{offers.map(OfferGroup)}
<div>
<Swirl /> <Swirl />
<div class='words'> <div class='words'>
<p>record importing service</p> <p>We bundle orders together from thousands of online stores to reduce the cost of shipping vinyl.</p>
<p>we bundle orders together from thousands of online stores to reduce the cost of shipping vinyl</p> <p>Based in Preston.</p>
<p>then we get together and play them</p> </div>
</div>
</div> </div>
</div> </div>
) )
-49
View File
@@ -1,49 +0,0 @@
import { h } from 'preact';
import { route } from 'preact-router';
import { useSelector } from 'react-redux'
import { State } from './../store';
import * as toast from './../toast';
import OfferGroup from './../components/offer.group';
const GroupNav = (want) => {
if (!want._count.offers) return null
const href = `#${want.release}`;
return (
<div class=''>
<a href={href}>{want.data.artists_sort} - {want.data.title}</a>
<span>{want._count.offers}</span>
</div>
);
};
function Home() {
const offers = useSelector((state: State) => state.offers);
const wants = useSelector((state: State) => state.wants);
if (offers && offers.length === 0) {
toast.info('no offers', 'You have no offers yet, search for some more items.');
route('/search');
}
if (!offers || !wants) return <div>loading...</div>;
return (
<main >
<nav>
<div class='header'>
<span>Record</span>
<span>Offers</span>
</div>
{wants.map(GroupNav)}
</nav>
<div class={'offers'}>
{offers.map(OfferGroup)}
</div>
</main>
);
}
export default Home;
+28
View File
@@ -0,0 +1,28 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { useSelector, useDispatch } from 'react-redux'
import http from './../http';
import { State, offersState } from './../store';
import OfferGroup from './../components/offer.group';
function Home() {
const dispatch = useDispatch()
const offers = useSelector((state: State) => state.offers);
if (offers && offers.length === 0) {
route('/search');
}
if (!offers) return <div>loading...</div>;
return (
<div class={'offers'}>
{offers.map(OfferGroup)}
</div>
);
}
export default Home;
+25 -35
View File
@@ -1,31 +1,28 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks'; import { useState, useRef, useEffect } from 'preact/hooks';
import { ExternalLink, Check } from 'react-feather'; import { ExternalLink, Check } from 'react-feather';
import { useSelector } from 'react-redux'
import { State } from './../store';
import { info, error as toastError } from './../toast';
import http from '../http'; import http from '../http';
function Result(r, wants, ws) { function Result(r) {
const [added, setAdded] = useState(false)
async function onClick() { async function onClick() {
ws.sendWantAdd(r.id); const [error, want] = await http.postWant(r.id);
if (error) return false;
setAdded(true);
} }
// releaseIds are bigints on server const classes = `${added ? 'added' : ''}`;
const wanted = wants.some(w => BigInt(w.release) === BigInt(r.id)); const btnText = added
const classes = `${wanted ? 'added' : ''}`;
const btnText = wanted
? <Check /> ? <Check />
: '+'; : '+';
return ( return (
<article key={r.id}> <article key={r.id}>
<button onClick={onClick} disabled={wanted} class={classes} >{btnText}</button> <button onClick={onClick} disabled={added} class={classes} >{btnText}</button>
<div> <div>
<h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank" rel="noreferrer"><ExternalLink /></a></h3> <h3>{r.title} <a href={`https://discogs.com${r.uri}`} target="_blank"><ExternalLink /></a></h3>
<h5 class='alt-text' >{r.year}</h5> <h5 class='alt-text' >{r.year}</h5>
<h5 class='alt-text'>{r.country}</h5> <h5 class='alt-text'>{r.country}</h5>
<h5 class='alt-text'>{r.format.join(' ')}</h5> <h5 class='alt-text'>{r.format.join(' ')}</h5>
@@ -37,46 +34,34 @@ function Result(r, wants, ws) {
const User = () => { const User = () => {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [title, setTitle] = useState('');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [infoText, setInfoText] = useState('');
const wants = useSelector((state: State) => state.wants);
const ws = useSelector((state: State) => state.ws);
async function fetchResults() { async function fetchResults() {
const [error, results] = await http.postWantSearch(query); const [error, results] = await http.postWantSearch(query);
if (error) {
setResults([]);
toastError('Error', error);
}
if (results.length === 0) { if (results.length === 0) {
setResults([]); setResults([]);
return info('No Results', 'No results returned from search'); return setInfoText('No results');
} }
setInfoText('');
setResults(results); setResults(results);
} }
async function submitWantlist() { async function submitWantlist() {
const [error, wants] = await http.postDiscogs(username); const [error, wants] = await http.postDiscogs(username);
if (error) {
setResults([]);
toastError('Error', error);
}
if (wants.length === 0) { if (wants.length === 0) {
setResults([]); setResults([]);
return info('Success', 'No new additions'); return setInfoText('No new additions');
} }
info('Success', `Added ${wants.length} items to your list.`); setInfoText(`Added ${wants.length} items to your list.`);
} }
const searchRef = useRef<HTMLInputElement>(); const searchRef = useRef<HTMLInputElement>()
const resultsRef = useRef<HTMLDivElement>();
useEffect(() => { useEffect(() => {
searchRef.current.focus() searchRef.current.focus()
@@ -93,7 +78,11 @@ const User = () => {
} }
const topEl = results.length const topEl = results.length
? <button onClick={() => resultsRef.current.scrollTo(0, 0)}> top </button> ? <button onClick={() => window.scrollTo(0, 0)}> top </button>
: null;
const infoEl = infoText
? <article><div></div><div>{infoText}</div></article>
: null; : null;
return ( return (
@@ -115,8 +104,9 @@ const User = () => {
<hr /> <hr />
<p>Data provided by Discogs.</p> <p>Data provided by Discogs.</p>
</div> </div>
<div class='results' ref={resultsRef}> <div class='results'>
{results.map(r => Result(r, wants, ws))} {infoEl}
{results.map(Result)}
{topEl} {topEl}
</div> </div>
</div> </div>
+1 -2
View File
@@ -11,7 +11,7 @@ export const userState = createSlice({
export const offersState = createSlice({ export const offersState = createSlice({
name: 'offers', name: 'offers',
initialState: null, initialState: [],
reducers: { reducers: {
set: (state, action) => action.payload set: (state, action) => action.payload
}, },
@@ -52,7 +52,6 @@ const reducer = combineReducers({
const store = createStore( const store = createStore(
reducer, reducer,
// @ts-ignore -- devtools window exists on dev
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
); );
-87
View File
@@ -1,87 +0,0 @@
/* vietnamese */
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_A8s52Hs.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_Ass52Hs.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/barlow/v12/7cHpv4kjgoGqM7E_DMs5.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09DdVXQQ.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09ndVXQQ.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09HdVXQQ.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ097dVXQQ.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09LdVXQQ.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ09PdVXQQ.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/s/notosansmono/v21/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ093dVQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+359 -12
View File
@@ -23,6 +23,32 @@ img {
height: auto; height: auto;
} }
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/raleway/v28/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvaorCIPrE.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/sourcecodepro/v21/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQtMlrTA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Noto Sans Mono';
font-style: normal;
font-weight: 400;
font-stretch: normal;
src: url(https://fonts.gstatic.com/s/notosansmono/v20/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ093dVQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
// stuff from UX laws // stuff from UX laws
html { html {
color:var(--base-font-color); color:var(--base-font-color);
@@ -45,7 +71,7 @@ html {
--medium-font-size: 1.777em; --medium-font-size: 1.777em;
--large-font-size: 2.369em; --large-font-size: 2.369em;
--xlarge-font-size: 3.157em; --xlarge-font-size: 3.157em;
--base-font-family: 'Barlow', Arial, Sans-Serif; --base-font-family: 'Raleway', Arial, Sans-Serif;
--secondary-font-family: 'Noto Sans Mono', monospace; --secondary-font-family: 'Noto Sans Mono', monospace;
--base-font-color: var(--color-off-white); --base-font-color: var(--color-off-white);
--secondary-font-color: var(--color-mid-grey); --secondary-font-color: var(--color-mid-grey);
@@ -75,7 +101,6 @@ html {
--color-blue: #475977; --color-blue: #475977;
--color-green: #476d4a; --color-green: #476d4a;
--color-red: #ae4442;
--color-accepted: var(--color-blue); --color-accepted: var(--color-blue);
--color-confirmed: var(--color-green); --color-confirmed: var(--color-green);
@@ -106,11 +131,6 @@ button, input, optgroup, select, textarea {
line-height:var(--base-line-height); line-height:var(--base-line-height);
} }
button, [type=submit] {
padding: var(--spacing-half);
background: var(--background-1);
}
input:hover, textarea:hover { input:hover, textarea:hover {
color:var(--base-font-color); color:var(--base-font-color);
border:var(--hover-border) border:var(--hover-border)
@@ -136,7 +156,7 @@ button, [type=button], [type=reset], [type=submit] {
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
font-size: var(--small-font-size); font-size: var(--small-font-size);
// font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
&:hover { &:hover {
@@ -174,6 +194,178 @@ p {
margin:0 0 var(--spacing) margin:0 0 var(--spacing)
} }
// ok actual styles
body {
padding-top: calc(var(--spacing)/2);
}
main {
padding: 0 var(--spacing-double) 0 var(--spacing-double);
}
button, [type=submit] {
padding: var(--spacing-half);
background: var(--background-1);
}
.landing {
display: grid;
grid-template-columns: 1fr 2fr;
grid-gap: calc(var(--spacing-double) * 2);
grid-template-areas:
"login swirl"
"login sample"
"login words";
.login {
grid-area: login;
button {
width: 100%;
}
}
.swirl {
// grid-area: swirl;
height: 5rem;
}
.words {
grid-area: words;
text-align: center;
}
}
.info {
display: grid;
grid-template-areas:
"words swirl"
"words contact";
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content 1fr;
section {
margin-bottom: var(--spacing);
}
.contact {
grid-area: contact;
text-align: center;
}
.words {
grid-area: words;
}
.swirl {
grid-area: swirl;
height: 15rem;
}
}
.alt-text, .alt {
font-family: var(--secondary-font-family);
// font-size: var(--small-font-size);
text-transform: uppercase;
}
article {
background-color: var(--background-1);
button {
border-color: var(--background-2);
}
}
.search {
display: grid;
grid-template-columns: 1fr 2fr;
.results {
display: grid;
padding-left: var(--spacing);
grid-gap: var(--spacing);
article {
display: grid;
padding: var(--spacing);
grid-template-columns: 1fr 4fr min-content;
}
}
button {
margin-right: var(--spacing);
background: var(--background-2);
border-color: var(--background-1);
&.added {
background: var(--color-green);
cursor: auto;
}
}
}
.list {
display: grid;
grid-template-columns:
1fr 1fr 1fr;
grid-gap: var(--spacing);
article {
display: grid;
// grid-template-rows:
// 1fr min-content min-content;
grid-template-areas:
"title img"
"details details"
"remove remove";
button {
margin-top: var(--spacing-half);
grid-area: remove
}
.details {
grid-area: details;
}
padding: var(--spacing);
img {
justify-self: end;
}
}
}
form {
label, input {
display: block;
}
input, button {
width: 100%;
}
input {
padding: var(--spacing-half);
margin-bottom: var(--spacing);
}
label {
font-family: var(--secondary-font-family);
font-size: var(--small-font-size);
text-transform: uppercase;
margin-bottom: var(--spacing-half);
}
}
a { a {
color: var(--secondary-font-color); color: var(--secondary-font-color);
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
@@ -184,11 +376,166 @@ a {
} }
} }
.alt-text, .alt { nav {
a {
font-size: var(--small-font-size);
margin-right: var(--spacing);
}
}
header {
display: grid;
grid-template-columns: 1fr 1fr min-content;
padding-bottom: var(--spacing);
a {
white-space: nowrap;
}
}
.bundles {
flex-flow: row;
.bundle {
margin-bottom: var(--spacing-double);
section {
margin-bottom: var(--spacing);
}
.items {
display: grid;
grid-template-columns: repeat(5, 1fr);
div {
margin-right: var(--spacing);
margin-bottom: var(--spacing);
display: grid;
&.bold {
font-weight: bold;
background: var(--background-2);
}
}
}
}
}
.offers {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing);
.offer {
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: min-content min-content;
grid-template-areas:
"details img"
"lines lines";
padding: var(--spacing);
background: var(--background-1);
.details {
font-size: var(--small-font-size);
}
.status {
margin-top: var(--spacing-half);
}
section {
grid-area: details;
}
img {
grid-area: img;
justify-self: end;
margin-left: var(--spacing-half);
}
.lines {
grid-area: lines;
display: flex;
flex-wrap: wrap;
column-gap: var(--spacing-half);
button {
margin-top: var(--spacing-half);
border-color: var(--background-2);
&.accepted {
background: var(--color-accepted);
}
&.confirmed {
cursor: auto;
background: var(--color-confirmed);
}
&.neg {
margin-left: var(--spacing-half);
}
}
}
}
}
.number {
font-family: var(--secondary-font-family); font-family: var(--secondary-font-family);
// font-size: var(--small-font-size);
text-transform: uppercase; text-transform: uppercase;
} }
@import 'fonts.less'; .swirl {
@import 'styles.less'; background-image: url("./../assets/swirl.fix.svg");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: var(--spacing);
}
@media (max-width:900px) {
.landing {
grid-template-columns: 1fr;
grid-template-areas:
"swirl"
"login"
"words";
}
.list {
grid-template-columns: 1fr;
}
.search {
grid-template-columns: 1fr;
}
.offers {
grid-template-columns: 1fr;
}
.info {
grid-template-columns: 1fr;
// is necessary for some reason
grid-template-areas:
"swirl"
"contact"
"words";
}
main {
padding: 0 var(--spacing) 0 var(--spacing);
}
}
@media (max-width:1200px) {
img {
width: 5rem;
}
}
-422
View File
@@ -1,422 +0,0 @@
body {
padding-top: calc(var(--spacing)/2);
overflow: hidden;
}
#mcrates {
padding: 0 var(--spacing-double) var(--spacing) var(--spacing-double);
display: grid;
grid-template-areas:
"header header"
"main main"
"footer footer";
height: 100vh;
width: 100vw;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: min-content 1fr min-content;
}
.landing {
display: grid;
overflow: scroll;
grid-template-columns: 1fr 2fr;
grid-gap: calc(var(--spacing-double) * 2);
grid-template-areas:
"login swirl"
"login words";
.login {
grid-area: login;
button {
width: 100%;
}
}
.swirl {
grid-area: swirl;
height: 15rem;
}
.words {
grid-area: words;
text-align: center;
}
}
.info {
display: grid;
overflow: hidden;
grid-template-areas:
"words swirl"
"words contact";
grid-template-columns: 1fr 1fr;
grid-template-rows: min-content 1fr;
section {
margin-bottom: var(--spacing);
}
.contact {
grid-area: contact;
text-align: center;
overflow: scroll;
}
.words {
grid-area: words;
overflow: scroll;
}
.swirl {
grid-area: swirl;
height: 15rem;
}
}
article {
background-color: var(--background-1);
button {
border-color: var(--background-2);
}
}
.search {
display: grid;
grid-template-columns: 1fr 2fr;
overflow: hidden;
.results {
display: grid;
padding-left: var(--spacing);
grid-gap: var(--spacing);
article {
display: grid;
padding: var(--spacing);
grid-template-columns: 1fr 4fr min-content;
}
overflow: scroll;
}
button {
margin-right: var(--spacing);
background: var(--background-2);
border-color: var(--background-1);
&.added {
background: var(--color-green);
cursor: auto;
}
}
}
.list {
display: grid;
grid-template-columns:
1fr 1fr 1fr;
grid-gap: var(--spacing);
overflow: scroll;
article {
display: grid;
grid-template-areas:
"title img"
"details details"
"remove remove";
button {
margin-top: var(--spacing-half);
grid-area: remove
}
.details {
grid-area: details;
}
padding: var(--spacing);
img {
justify-self: end;
}
}
}
form {
margin-bottom: var(--spacing);
label, input {
display: block;
}
input, button {
width: 100%;
}
input {
padding: var(--spacing-half);
margin-bottom: var(--spacing);
}
label {
font-family: var(--secondary-font-family);
font-size: var(--small-font-size);
text-transform: uppercase;
margin-bottom: var(--spacing-half);
}
}
header {
grid-area: header;
display: grid;
grid-template-columns: 1fr 1fr min-content;
border-bottom: var(--base-border);
padding-bottom: var(--spacing-half);
margin-bottom: var(--spacing-half);
a {
font-size: var(--small-font-size);
margin-right: var(--spacing);
}
a {
white-space: nowrap;
}
}
main {
grid-area: main;
display: grid;
grid-template-areas:
"nav content";
grid-template-columns:
1fr 1fr;
column-gap: var(--spacing);
overflow: hidden;
nav {
// duck scrollbars
padding-right: var(--spacing-half);
grid-area: nav;
div {
display: grid;
grid-template-columns:
1fr min-content min-content;
column-gap: var(--spacing-half);
a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
overflow: scroll;
}
.offers {
grid-area: content;
overflow: scroll;
}
}
.acp {
overflow: scroll;
.bundles {
flex-flow: row;
.bundle {
margin-bottom: var(--spacing-double);
section {
margin-bottom: var(--spacing);
}
.items {
display: grid;
grid-template-columns: repeat(5, 1fr);
div {
margin-right: var(--spacing);
margin-bottom: var(--spacing);
display: grid;
&.bold {
font-weight: bold;
background: var(--background-2);
}
}
}
}
}
}
.offers {
display: grid;
// grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing);
.offer {
display: grid;
grid-template-columns: 1fr min-content;
grid-template-rows: min-content min-content;
grid-template-areas:
"details img"
"lines lines";
padding: var(--spacing);
background: var(--background-1);
.details {
font-size: var(--small-font-size);
}
.status {
margin-top: var(--spacing-half);
}
section {
grid-area: details;
}
img {
grid-area: img;
justify-self: end;
margin-left: var(--spacing-half);
}
.lines {
grid-area: lines;
display: flex;
flex-wrap: wrap;
column-gap: var(--spacing-half);
button {
margin-top: var(--spacing-half);
border-color: var(--background-2);
&.accepted {
background: var(--color-accepted);
}
&.confirmed {
cursor: auto;
background: var(--color-confirmed);
}
&.neg {
margin-left: var(--spacing-half);
}
}
}
}
}
.number {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}
.swirl {
background-image: url("./../assets/swirl.fix.svg");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: var(--spacing);
}
input[type='range']{ accent-color: var(--color-accepted); }
@media (max-width:900px) {
.landing {
grid-template-columns: 1fr;
grid-template-areas:
"swirl"
"login"
"words";
}
.list {
grid-template-columns: 1fr;
}
.search {
grid-template-columns: 1fr;
}
.offers {
grid-template-columns: 1fr;
}
.info {
grid-template-columns: 1fr;
// is necessary for some reason
grid-template-areas:
"swirl"
"contact"
"words";
}
#mcrates {
padding: 0 var(--spacing) 0 var(--spacing);
}
main {
grid-template-columns: 1fr;
grid-template-rows: 1fr 3fr;
grid-gap: var(--spacing-half);
grid-template-areas:
"nav"
"content";
}
}
@media (max-width:1200px) {
img {
width: 5rem;
}
}
// ------------
// toasts
.iziToast {
border-radius: 0;
border-color: var(--base-border-color);
.iziToast-body {
font-family: var(--base-font-family);
font-size: var(--base-font-size);
}
.iziToast-title {
font-family: var(--secondary-font-family);
text-transform: uppercase;
}
}
+1 -2
View File
@@ -2,11 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>crates - record import service</title> <title>matescrates.au - record import service</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<% preact.headEnd %> <% preact.headEnd %>
</head> </head>
<body> <body>
-30
View File
@@ -1,30 +0,0 @@
import toast from 'izitoast';
export function info(title, message) {
toast.info({
title,
message: message || '',
position: 'bottomCenter',
color: '#1f2128',
titleColor: '#F4F1D0',
messageColor: '#F4F1D0',
iconColor: '#F4F1D0',
icon: '',
theme: 'dark',
});
}
export function error(title, message) {
toast.error({
title,
message: message || '',
position: 'bottomCenter',
color: '#ae4442',
titleColor: '#F4F1D0',
messageColor: '#F4F1D0',
iconColor: '#F4F1D0',
icon: '',
theme: 'dark',
});
}
+7 -24
View File
@@ -1,7 +1,4 @@
import throttle from 'lodash/throttle'; import { wantsState, wsState, userState, offersState, acpOffersState } from './store'
import { wantsState, userState, offersState, acpOffersState } from './store'
import * as toast from './toast';
const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`; const SOCKET_URL = `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}/api/ws`;
@@ -29,18 +26,14 @@ export default function createSocket(dispatch) {
// setTimeout(sendPing, 50); // setTimeout(sendPing, 50);
} }
function sendOfferStatusSet(ids, status) { function sendOfferStatusSet(id, status) {
send(['OfferStatusSet', { ids, status }]); send(['OfferStatusSet', { id, status }]);
} }
function sendWantRm(id) { function sendWantRm(id) {
send(['WantRm', { id }]); send(['WantRm', { id }]);
} }
function sendWantAdd(releaseId) {
send(['WantAdd', { releaseId }]);
}
function sendOffers() { function sendOffers() {
send(['Offers', {}]); send(['Offers', {}]);
} }
@@ -57,10 +50,6 @@ export default function createSocket(dispatch) {
send(['AcpOfferStatusSet', { id, status }]); send(['AcpOfferStatusSet', { id, status }]);
} }
function sendAcpListingsSync() {
send(['AcpListingsSync', {}]);
}
let pongTimeout; let pongTimeout;
function onPong() { function onPong() {
pongTimeout = setTimeout(sendPing, 5000); pongTimeout = setTimeout(sendPing, 5000);
@@ -77,13 +66,7 @@ export default function createSocket(dispatch) {
dispatch(wantsState.actions.set(wants)); dispatch(wantsState.actions.set(wants));
} }
const offersNotification = throttle((offers) => {
if (!offers.length) return false;
toast.info('Offers Updated', 'New offers available.')
}, 60 * 1000);
function onOffers(offers) { function onOffers(offers) {
offersNotification(offers);
dispatch(offersState.actions.set(offers)); dispatch(offersState.actions.set(offers));
} }
@@ -101,6 +84,7 @@ export default function createSocket(dispatch) {
Offers: onOffers, Offers: onOffers,
User: onUser, User: onUser,
OfferSamples: onOffers,
AcpOffers: onAcpOffers, AcpOffers: onAcpOffers,
Pong: onPong, Pong: onPong,
@@ -127,8 +111,10 @@ export default function createSocket(dispatch) {
// Connection opened // Connection opened
function onOpen() { function onOpen() {
console.log('websocket connected', SOCKET_URL) console.log('websocket connected', SOCKET_URL)
toast.info('connected', 'connected to server');
sendPing(); sendPing();
sendAcpOffers();
sendOffers();
sendWants();
} }
function onError(event) { function onError(event) {
@@ -167,13 +153,10 @@ export default function createSocket(dispatch) {
sendOfferStatusSet, sendOfferStatusSet,
sendWantRm, sendWantRm,
sendWantAdd,
sendOffers, sendOffers,
sendWants,
sendAcpOffers, sendAcpOffers,
sendAcpOfferStatusSet, sendAcpOfferStatusSet,
sendAcpListingsSync,
}; };
} }
-14
View File
@@ -1,14 +0,0 @@
version: "3.3"
services:
client:
image: registry.ntwl.xyz/crates-client
build:
context: ./client
dockerfile: ./client.Dockerfile
api:
image: registry.ntwl.xyz/crates-api
build:
context: ./api
dockerfile: ./api.Dockerfile
+35
View File
@@ -0,0 +1,35 @@
version: "3.3"
services:
pg:
image: postgres:latest
environment:
POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'mcrates'
POSTGRES_PASSWORD: 'digdeep'
# allow prisma client to push changes from outside docker etc
# ufw blocks this from being publicly accessible
ports:
- '45432:5432'
volumes:
- "./db:/var/lib/postgresql/data"
api:
image: node:16
working_dir: /opt/mcrates/api
entrypoint: npm start
environment:
DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
volumes:
- "./api:/opt/mcrates/api"
nginx:
image: nginx:latest
volumes:
- './nginx/matescrates.au.nginx.conf:/etc/nginx/conf.d/default.conf'
- './client/build:/srv/matescrates'
- '/etc/letsencrypt:/etc/letsencrypt'
ports:
- '80:80'
- '443:443'
+4 -4
View File
@@ -4,18 +4,18 @@ services:
pg: pg:
image: postgres:latest image: postgres:latest
environment: environment:
POSTGRES_USER: 'crates' POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'test' POSTGRES_DB: 'test'
POSTGRES_PASSWORD: 'digdeep' POSTGRES_PASSWORD: 'digdeep'
api: api:
image: node:16 image: node:16
working_dir: /opt/crates/api working_dir: /opt/mcrates/api
entrypoint: npm run test:watch entrypoint: npm run test:watch
depends_on: depends_on:
- pg - pg
environment: environment:
DATABASE_URL: "postgresql://crates:digdeep@pg:5432/test?schema=public" DATABASE_URL: "postgresql://mcrates:digdeep@pg:5432/test?schema=public"
volumes: volumes:
- "./api:/opt/crates/api" - "./api:/opt/mcrates/api"
+13 -33
View File
@@ -4,54 +4,34 @@ services:
pg: pg:
image: postgres:latest image: postgres:latest
environment: environment:
POSTGRES_USER: '${POSTGRES_USER}' POSTGRES_USER: 'mcrates'
POSTGRES_DB: '${POSTGRES_DB}' POSTGRES_DB: 'mcrates'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' POSTGRES_PASSWORD: 'digdeep'
volumes:
# so ez
# insane
- "./pg:/docker-entrypoint-initdb.d"
ports: ports:
- '45432:5432' - '45432:5432'
volumes:
- "./db:/var/lib/postgresql/data"
api: api:
image: crates/api image: node:16
build: working_dir: /opt/mcrates/api
context: ./api
dockerfile: ./api.Dockerfile
working_dir: /opt/crates/api
depends_on: depends_on:
- 'pg' - 'pg'
command: npm start entrypoint: npm start
environment: environment:
DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public' DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
DISCORD_TOKEN: '${DISCORD_TOKEN}'
DISCOGS_TOKEN: '${DISCOGS_TOKEN}'
CHOKIDAR_USEPOLLING: 1
volumes: volumes:
- "./api:/opt/crates/api" - "./api:/opt/mcrates/api"
- "/opt/crates/api/node_modules"
migrate:
restart: on-failure:3
extends: api
command: npm run migrate status
client: client:
image: node:16 image: node:16
working_dir: /opt/crates/client working_dir: /opt/mcrates/client
build:
context: ./client
dockerfile: ./client.dev.Dockerfile
entrypoint: npm start entrypoint: npm start
ports: ports:
- '8080:8080' # fwd the hmr websocket - '8080:8080' # fwd the hmr websocket
environment:
CHOKIDAR_USEPOLLING: 1
volumes: volumes:
- "./client:/opt/crates/client" - "./client:/opt/mcrates/client"
- "./api:/opt/crates/api" - "./api:/opt/mcrates/api"
- "/opt/crates/client/node_modules"
nginx: nginx:
image: nginx:latest image: nginx:latest
-2
View File
@@ -1,2 +0,0 @@
dev:
docker compose up
+5 -5
View File
@@ -1,8 +1,8 @@
upstream crates_api { upstream mcrates_api {
server api:41337; server api:41337;
} }
upstream crates_client { upstream mcrates_client {
server client:8080; server client:8080;
} }
@@ -13,12 +13,12 @@ map $http_upgrade $connection_upgrade {
server { server {
location / { location / {
proxy_pass http://crates_client; proxy_pass http://mcrates_client;
proxy_read_timeout 600s; proxy_read_timeout 600s;
} }
location /api/ws { location /api/ws {
proxy_pass http://crates_api; proxy_pass http://mcrates_api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
@@ -28,7 +28,7 @@ server {
location /api/ { location /api/ {
# trailing slash means api will receive url WITHOUT /api # trailing slash means api will receive url WITHOUT /api
proxy_pass http://crates_api/; proxy_pass http://mcrates_api/;
proxy_read_timeout 600s; proxy_read_timeout 600s;
} }
} }