diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fef34f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Auction Space — copy to .env.local (never commit secrets) +# See docs/auction/DECISIONS.md + +# D1 — Postgres (Neon, Supabase, or any hosted Postgres) +DATABASE_URL=postgres://user:password@host:5432/hackerdojo_auction?sslmode=require + +# D2 — Sessions (generate with: openssl rand -hex 32) +SESSION_SECRET=replace-with-at-least-32-random-bytes +SITE_URL=http://localhost:4000 + +# Slice 0 — admin seed +ADMIN_EMAIL=admin@hackerdojo.org +ADMIN_NAME=Auction Admin + +# D3 — Resend (login OTP + bid emails; optional for local — returns dev_otp) +RESEND_API_KEY= +EMAIL_FROM="Hacker Dojo Auction " +# Force OTP in API JSON even in production-like envs (local only) +# AUCTION_DEV_OTP=1 + +# Slice 4 — cron (required for ending-soon + auto-close job) +AUCTION_CRON_SECRET= +# Hours before end to send "ending soon" (default 24) +# AUCTION_ENDING_SOON_HOURS=24 +# AUCTION_PICKUP_BLURB=Staff will contact you about payment and pickup. + +# Slice 5 +CORS_ORIGIN=http://localhost:4000,https://hackerdojo.org diff --git a/.gitignore b/.gitignore index ff45941..0374e61 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ .gstack .vercel .wrangler +node_modules/ +.env +.env.local +.env.*.local +package-lock.json.bak **/*.*~ diff --git a/Gemfile b/Gemfile index 7334b63..892e898 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,10 @@ source "https://rubygems.org" gem "jekyll", "~> 4.3.0" gem "minima", "~> 2.5" +# Ruby 3.4+ / 4.x — no longer default gems +gem "csv" +gem "base64" +gem "logger" group :jekyll_plugins do gem "jekyll-feed" diff --git a/Gemfile.lock b/Gemfile.lock index 9ff2039..585c541 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,9 +3,11 @@ GEM specs: addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) bigdecimal (4.1.2) colorator (1.1.0) concurrent-ruby (1.3.6) + csv (3.3.6) em-websocket (0.5.3) eventmachine (>= 0.12.9) http_parser.rb (~> 0) @@ -159,16 +161,21 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + base64 + csv jekyll (~> 4.3.0) jekyll-feed + logger minima (~> 2.5) CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38 concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8 eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972 ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf diff --git a/README.md b/README.md index dbfb42a..7fb9f43 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,34 @@ Once all pre-requisites are installed, you can preview the website using: jekyll serve ``` +## Auction Space (Silent Auction) + +Fundraising silent auction for art (and donated lots), planned and implemented in slices on top of this site. + +| Doc | Purpose | +|-----|---------| +| [docs/auction/START_HERE.md](./docs/auction/START_HERE.md) | Reading order | +| [docs/auction/DECISIONS.md](./docs/auction/DECISIONS.md) | Infrastructure choices (Postgres, magic-link, Resend, Vercel) | +| [docs/auction/SLICES.md](./docs/auction/SLICES.md) | Build order | +| [docs/auction/SLICE_0_RUNBOOK.md](./docs/auction/SLICE_0_RUNBOOK.md) | Bootstrap (DB migrate + admin seed) | +| [docs/auction/SLICE_1_RUNBOOK.md](./docs/auction/SLICE_1_RUNBOOK.md) | Browse (gallery + detail + countdown) | +| [docs/auction/SLICE_2_RUNBOOK.md](./docs/auction/SLICE_2_RUNBOOK.md) | Bid (OTP login + place bid) | +| [docs/auction/SLICE_3_RUNBOOK.md](./docs/auction/SLICE_3_RUNBOOK.md) | Admin (create / edit / close) | +| [docs/auction/SLICE_4_RUNBOOK.md](./docs/auction/SLICE_4_RUNBOOK.md) | Emails + ending-soon / auto-close cron | + +### Auction bootstrap (Slices 0–1) + +Requires Node 20+ and a Postgres `DATABASE_URL`. + +```sh +cp .env.example .env.local # set DATABASE_URL, SESSION_SECRET, ADMIN_EMAIL +npm install +npm run auction:bootstrap # migrate + seed admin + demo lot +``` + +- Health: `GET /api/auction/health` +- Gallery: `/auction/` +- Lot detail: `/auction/artwork/?id=` ## Silent Auction MVP (Planning) See [docs/auction](./docs/auction). diff --git a/_includes/footer.html b/_includes/footer.html index 00a9dec..8855ced 100644 --- a/_includes/footer.html +++ b/_includes/footer.html @@ -15,6 +15,7 @@ Accelerator AI Stars Summer Camp + Auction Wiki
diff --git a/_includes/header.html b/_includes/header.html index 023cc15..8a1aecd 100644 --- a/_includes/header.html +++ b/_includes/header.html @@ -50,6 +50,7 @@ Startups Pricing Impact Report + Auction Donate
@@ -87,6 +88,7 @@ Startups Pricing Impact Report + Auction Donate Dojo Earth 🌍 Take A Tour diff --git a/api/auction/admin/artworks.js b/api/auction/admin/artworks.js new file mode 100644 index 0000000..1091513 --- /dev/null +++ b/api/auction/admin/artworks.js @@ -0,0 +1,37 @@ +/** + * GET /api/auction/admin/artworks — all statuses (admin) + * Optional ?id= for single lot + bid list + */ + +import { + listAdminArtworks, + listAdminBids, +} from '../../../lib/auction/admin-artworks.js'; +import { getArtworkById, serializeArtworkDetail } from '../../../lib/auction/artworks.js'; +import { requireAdmin } from '../../../lib/auction/auth.js'; +import { json, withHandler } from '../../../lib/auction/http.js'; + +export default withHandler(async function adminArtworks(req, res) { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, OPTIONS'); + return res.end(); + } + + await requireAdmin(req); + + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + const id = url.searchParams.get('id'); + + if (id) { + const row = await getArtworkById(id); + const bids = await listAdminBids(id); + return json(res, 200, { + artwork: serializeArtworkDetail(row), + bids, + }); + } + + const artworks = await listAdminArtworks(); + return json(res, 200, { artworks }); +}, { methods: ['GET', 'OPTIONS'] }); diff --git a/api/auction/admin/ping.js b/api/auction/admin/ping.js new file mode 100644 index 0000000..f28393f --- /dev/null +++ b/api/auction/admin/ping.js @@ -0,0 +1,36 @@ +/** + * GET /api/auction/admin/ping + * Authenticated admin health check (Slice 0 Done when). + */ + +import { requireAdmin } from '../../../lib/auction/auth.js'; +import { query } from '../../../lib/auction/db.js'; +import { json, withHandler } from '../../../lib/auction/http.js'; + +export default withHandler(async function adminPing(req, res) { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, OPTIONS'); + return res.end(); + } + + const admin = await requireAdmin(req); + const { rows } = await query( + `SELECT + (SELECT count(*)::int FROM auction_users) AS users, + (SELECT count(*)::int FROM auction_artworks) AS artworks, + (SELECT count(*)::int FROM auction_bids) AS bids` + ); + + return json(res, 200, { + ok: true, + admin: { + id: admin.id, + email: admin.email, + name: admin.name, + role: admin.role, + }, + counts: rows[0], + time: new Date().toISOString(), + }); +}, { methods: ['GET', 'OPTIONS'] }); diff --git a/api/auction/artworks.js b/api/auction/artworks.js new file mode 100644 index 0000000..4e04818 --- /dev/null +++ b/api/auction/artworks.js @@ -0,0 +1,41 @@ +/** + * GET /api/auction/artworks — public list + * POST /api/auction/artworks — admin create + */ + +import { listPublicArtworks } from '../../lib/auction/artworks.js'; +import { createArtwork } from '../../lib/auction/admin-artworks.js'; +import { requireAdmin } from '../../lib/auction/auth.js'; +import { readJsonBody, json, withHandler } from '../../lib/auction/http.js'; +import { apiError, ErrorCodes } from '../../lib/auction/errors.js'; + +export default withHandler(async function artworks(req, res) { + if (req.method === 'GET') { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + const status = url.searchParams.get('status') || 'active'; + const limit = url.searchParams.get('limit'); + const offset = url.searchParams.get('offset'); + + if (status && !['active', 'preview', 'closed', 'all_public'].includes(status)) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Invalid status filter'); + } + + const artworks = await listPublicArtworks({ + status, + limit: limit ? Number(limit) : 50, + offset: offset ? Number(offset) : 0, + }); + return json(res, 200, { artworks }); + } + + if (req.method === 'POST') { + const admin = await requireAdmin(req); + const body = await readJsonBody(req); + const artwork = await createArtwork(body, admin.id); + return json(res, 201, { artwork }); + } + + res.statusCode = 405; + res.setHeader('Allow', 'GET, POST, OPTIONS'); + return res.end(); +}, { methods: ['GET', 'POST', 'OPTIONS'] }); diff --git a/api/auction/artworks/[id].js b/api/auction/artworks/[id].js new file mode 100644 index 0000000..160a08d --- /dev/null +++ b/api/auction/artworks/[id].js @@ -0,0 +1,52 @@ +/** + * GET /api/auction/artworks/:id — public detail + * PATCH /api/auction/artworks/:id — admin update + * DELETE /api/auction/artworks/:id — admin delete draft + */ + +import { getPublicArtworkDetail } from '../../../lib/auction/artworks.js'; +import { + patchArtwork, + deleteDraftArtwork, +} from '../../../lib/auction/admin-artworks.js'; +import { requireAdmin } from '../../../lib/auction/auth.js'; +import { readJsonBody, json, withHandler } from '../../../lib/auction/http.js'; +import { apiError, ErrorCodes } from '../../../lib/auction/errors.js'; + +function artworkId(req) { + const id = + req.query?.id || + (req.url && req.url.match(/\/artworks\/([^/?#]+)/)?.[1]) || + null; + if (!id) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Missing artwork id'); + } + return decodeURIComponent(String(id)); +} + +export default withHandler(async function artworkById(req, res) { + const id = artworkId(req); + + if (req.method === 'GET') { + const payload = await getPublicArtworkDetail(id); + return json(res, 200, payload); + } + + if (req.method === 'PATCH') { + const admin = await requireAdmin(req); + const body = await readJsonBody(req); + const artwork = await patchArtwork(id, body, admin.id); + return json(res, 200, { artwork }); + } + + if (req.method === 'DELETE') { + const admin = await requireAdmin(req); + await deleteDraftArtwork(id, admin.id); + res.statusCode = 204; + return res.end(); + } + + res.statusCode = 405; + res.setHeader('Allow', 'GET, PATCH, DELETE, OPTIONS'); + return res.end(); +}, { methods: ['GET', 'PATCH', 'DELETE', 'OPTIONS'] }); diff --git a/api/auction/artworks/[id]/close.js b/api/auction/artworks/[id]/close.js new file mode 100644 index 0000000..4fc45dc --- /dev/null +++ b/api/auction/artworks/[id]/close.js @@ -0,0 +1,33 @@ +/** + * POST /api/auction/artworks/:id/close — admin close lot + set winner + */ + +import { closeArtwork } from '../../../../lib/auction/admin-artworks.js'; +import { requireAdmin } from '../../../../lib/auction/auth.js'; +import { json, withHandler } from '../../../../lib/auction/http.js'; +import { apiError, ErrorCodes } from '../../../../lib/auction/errors.js'; + +function artworkId(req) { + // Vercel: /api/auction/artworks/:id/close + const fromQuery = req.query?.id; + if (fromQuery) return decodeURIComponent(String(fromQuery)); + const m = (req.url || '').match(/\/artworks\/([^/?#]+)\/close/); + if (m) return decodeURIComponent(m[1]); + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Missing artwork id'); +} + +export default withHandler(async function closeHandler(req, res) { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Allow', 'POST, OPTIONS'); + return res.end(); + } + + const admin = await requireAdmin(req); + const id = artworkId(req); + const artwork = await closeArtwork(id, { + id: admin.id, + email: admin.email, + }); + return json(res, 200, { artwork }); +}, { methods: ['POST', 'OPTIONS'] }); diff --git a/api/auction/auth/request-link.js b/api/auction/auth/request-link.js new file mode 100644 index 0000000..3709583 --- /dev/null +++ b/api/auction/auth/request-link.js @@ -0,0 +1,28 @@ +/** + * POST /api/auction/auth/request-link + * Body: { email, name? } + * Always returns generic ok to reduce enumeration; may include dev_otp in non-prod. + */ + +import { issueLoginToken } from '../../../lib/auction/login.js'; +import { readJsonBody, json, withHandler } from '../../../lib/auction/http.js'; + +export default withHandler(async function requestLink(req, res) { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Allow', 'POST, OPTIONS'); + return res.end(); + } + + const body = await readJsonBody(req); + const result = await issueLoginToken({ + email: /** @type {string} */ (body.email), + name: body.name ? String(body.name) : null, + }); + + return json(res, 200, { + ok: true, + message: 'If the email is valid, a login code was sent.', + ...(result.dev_otp ? { dev_otp: result.dev_otp } : {}), + }); +}, { methods: ['POST', 'OPTIONS'] }); diff --git a/api/auction/auth/session.js b/api/auction/auth/session.js new file mode 100644 index 0000000..9576f43 --- /dev/null +++ b/api/auction/auth/session.js @@ -0,0 +1,39 @@ +/** + * GET /api/auction/auth/session — current user (or null) + * DELETE /api/auction/auth/session — log out + * + * Also supports GET /api/auction/me via alias path if needed later. + */ + +import { + getSessionUser, + clearSessionCookie, +} from '../../../lib/auction/auth.js'; +import { json, withHandler } from '../../../lib/auction/http.js'; + +export default withHandler(async function session(req, res) { + if (req.method === 'GET') { + const user = await getSessionUser(req); + if (!user) { + return json(res, 200, { user: null }); + } + return json(res, 200, { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }, + }); + } + + if (req.method === 'DELETE') { + clearSessionCookie(res); + res.statusCode = 204; + return res.end(); + } + + res.statusCode = 405; + res.setHeader('Allow', 'GET, DELETE, OPTIONS'); + return res.end(); +}, { methods: ['GET', 'DELETE', 'OPTIONS'] }); diff --git a/api/auction/auth/verify.js b/api/auction/auth/verify.js new file mode 100644 index 0000000..1817143 --- /dev/null +++ b/api/auction/auth/verify.js @@ -0,0 +1,34 @@ +/** + * POST /api/auction/auth/verify + * Body: { email, token } + * Sets session cookie and returns user. + */ + +import { verifyLoginToken } from '../../../lib/auction/login.js'; +import { attachSessionCookie } from '../../../lib/auction/auth.js'; +import { readJsonBody, json, withHandler } from '../../../lib/auction/http.js'; + +export default withHandler(async function verify(req, res) { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Allow', 'POST, OPTIONS'); + return res.end(); + } + + const body = await readJsonBody(req); + const user = await verifyLoginToken({ + email: /** @type {string} */ (body.email), + token: /** @type {string} */ (body.token), + }); + + attachSessionCookie(res, user.id); + + return json(res, 200, { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }, + }); +}, { methods: ['POST', 'OPTIONS'] }); diff --git a/api/auction/bids.js b/api/auction/bids.js new file mode 100644 index 0000000..b486ee3 --- /dev/null +++ b/api/auction/bids.js @@ -0,0 +1,34 @@ +/** + * POST /api/auction/bids + * Body: { artwork_id | auction_id, amount } + */ + +import { requireUser } from '../../lib/auction/auth.js'; +import { placeBid } from '../../lib/auction/bids.js'; +import { readJsonBody, json, withHandler } from '../../lib/auction/http.js'; +import { apiError, ErrorCodes } from '../../lib/auction/errors.js'; + +export default withHandler(async function bids(req, res) { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Allow', 'POST, OPTIONS'); + return res.end(); + } + + const user = await requireUser(req); + const body = await readJsonBody(req); + const artworkId = body.artwork_id || body.auction_id; + if (!artworkId) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'artwork_id is required'); + } + + const result = await placeBid({ + artworkId: String(artworkId), + userId: user.id, + amount: body.amount, + userEmail: user.email, + userName: user.name, + }); + + return json(res, 201, result); +}, { methods: ['POST', 'OPTIONS'] }); diff --git a/api/auction/cron/ending-soon.js b/api/auction/cron/ending-soon.js new file mode 100644 index 0000000..23fdfd1 --- /dev/null +++ b/api/auction/cron/ending-soon.js @@ -0,0 +1,51 @@ +/** + * POST /api/auction/cron/ending-soon + * Secured by AUCTION_CRON_SECRET (Authorization: Bearer … or x-cron-secret). + * + * Runs ending-soon notices + auto-close for lots past ends_at. + */ + +import { getCronSecret } from '../../../lib/auction/config.js'; +import { runAuctionCron } from '../../../lib/auction/cron.js'; +import { apiError, ErrorCodes } from '../../../lib/auction/errors.js'; +import { json, withHandler } from '../../../lib/auction/http.js'; + +function authorize(req) { + // Prefer AUCTION_CRON_SECRET; also accept Vercel platform CRON_SECRET. + const expected = + getCronSecret() || + (process.env.CRON_SECRET ? String(process.env.CRON_SECRET).trim() : ''); + if (!expected) { + throw apiError( + ErrorCodes.CONFIG_ERROR, + 'AUCTION_CRON_SECRET (or CRON_SECRET) is not configured' + ); + } + const header = + req.headers['x-cron-secret'] || + req.headers['authorization'] || + req.headers['Authorization']; + const raw = Array.isArray(header) ? header[0] : header; + if (!raw) { + throw apiError(ErrorCodes.UNAUTHENTICATED, 'Missing cron secret'); + } + const token = String(raw).startsWith('Bearer ') + ? String(raw).slice(7).trim() + : String(raw).trim(); + if (token !== expected) { + throw apiError(ErrorCodes.FORBIDDEN, 'Invalid cron secret'); + } +} + +export default withHandler(async function endingSoonCron(req, res) { + // Allow GET for Vercel Cron (sends GET by default) + if (req.method !== 'POST' && req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, POST, OPTIONS'); + return res.end(); + } + + authorize(req); + const result = await runAuctionCron(); + return json(res, 200, { ok: true, ...result }); +}, { methods: ['GET', 'POST', 'OPTIONS'] }); diff --git a/api/auction/health.js b/api/auction/health.js new file mode 100644 index 0000000..56c617a --- /dev/null +++ b/api/auction/health.js @@ -0,0 +1,56 @@ +/** + * GET /api/auction/health + * Public: DB connectivity + schema presence. + * With session + admin role: returns admin ping details. + */ + +import { query } from '../../lib/auction/db.js'; +import { getSessionUser } from '../../lib/auction/auth.js'; +import { json, withHandler } from '../../lib/auction/http.js'; + +export default withHandler(async function health(req, res) { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, OPTIONS'); + return res.end(); + } + + let dbOk = false; + /** @type {string | null} */ + let schemaVersion = null; + /** @type {string | null} */ + let dbError = null; + + try { + await query('SELECT 1'); + dbOk = true; + const mig = await query( + `SELECT id FROM auction_schema_migrations ORDER BY applied_at DESC LIMIT 1` + ); + schemaVersion = mig.rows[0]?.id ?? null; + } catch (err) { + dbError = err instanceof Error ? err.message : 'db_error'; + } + + const user = await getSessionUser(req).catch(() => null); + const isAdmin = user?.role === 'admin'; + + return json(res, dbOk ? 200 : 503, { + ok: dbOk, + service: 'auction', + slice: 0, + database: dbOk ? 'up' : 'down', + schema_version: schemaVersion, + ...(dbError && !dbOk ? { error: dbError } : {}), + ...(isAdmin + ? { + admin: { + email: user.email, + user_id: user.id, + role: user.role, + }, + } + : {}), + time: new Date().toISOString(), + }); +}, { methods: ['GET', 'OPTIONS'] }); diff --git a/api/auction/me.js b/api/auction/me.js new file mode 100644 index 0000000..9a23fa7 --- /dev/null +++ b/api/auction/me.js @@ -0,0 +1,25 @@ +/** + * GET /api/auction/me + * Authenticated current user. + */ + +import { requireUser } from '../../lib/auction/auth.js'; +import { json, withHandler } from '../../lib/auction/http.js'; + +export default withHandler(async function me(req, res) { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, OPTIONS'); + return res.end(); + } + + const user = await requireUser(req); + return json(res, 200, { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }, + }); +}, { methods: ['GET', 'OPTIONS'] }); diff --git a/auction/admin.html b/auction/admin.html new file mode 100644 index 0000000..dec4177 --- /dev/null +++ b/auction/admin.html @@ -0,0 +1,21 @@ +--- +layout: default +title: Auction Admin | Hacker Dojo +permalink: /auction/admin/ +--- + + +
+
+
+
Loading admin…
+
+
+
+ + + diff --git a/auction/artwork.html b/auction/artwork.html new file mode 100644 index 0000000..ae633c7 --- /dev/null +++ b/auction/artwork.html @@ -0,0 +1,21 @@ +--- +layout: default +title: Auction Lot | Hacker Dojo +permalink: /auction/artwork/ +--- + + +
+
+
+
Loading artwork…
+
+
+
+ + + diff --git a/auction/index.html b/auction/index.html new file mode 100644 index 0000000..dfecc9a --- /dev/null +++ b/auction/index.html @@ -0,0 +1,37 @@ +--- +layout: default +title: Silent Auction | Hacker Dojo +permalink: /auction/ +--- + + +
+
Fundraising
+

Silent Auction

+

+ Browse donated lots and place your bid before the countdown ends. + Sign in with a one-time email code — no password required. + Proceeds support Hacker Dojo programs and community. +

+
+ +
+
+
Open Lots
+
Tap a lot to view details, bid history, and place a bid.
+
+
+ +
+
+ + + diff --git a/db/migrations/001_auction_init.sql b/db/migrations/001_auction_init.sql new file mode 100644 index 0000000..cb473cb --- /dev/null +++ b/db/migrations/001_auction_init.sql @@ -0,0 +1,119 @@ +-- Auction Space Slice 0 — core schema +-- Source of truth: docs/auction/DATA_MODEL.md +-- login_tokens supports magic-link / OTP (Slice 2); included so bootstrap is complete. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- --------------------------------------------------------------------------- +-- User +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS auction_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + name TEXT, + role TEXT NOT NULL DEFAULT 'bidder' + CHECK (role IN ('bidder', 'admin')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT auction_users_email_unique UNIQUE (email) +); + +CREATE INDEX IF NOT EXISTS auction_users_role_idx ON auction_users (role); + +-- Normalize emails to lowercase on write (application also lowercases). +-- Use citext if available; otherwise lower() unique index pattern: +CREATE UNIQUE INDEX IF NOT EXISTS auction_users_email_lower_idx + ON auction_users (lower(email)); + +-- --------------------------------------------------------------------------- +-- Artwork (auction lot) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS auction_artworks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + artist TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + images JSONB NOT NULL DEFAULT '[]'::jsonb, + starting_bid NUMERIC(12, 2) NOT NULL CHECK (starting_bid >= 0), + current_bid NUMERIC(12, 2) CHECK (current_bid IS NULL OR current_bid >= 0), + minimum_increment NUMERIC(12, 2) NOT NULL CHECK (minimum_increment > 0), + ends_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'preview', 'active', 'closed')), + winner_user_id UUID REFERENCES auction_users (id) ON DELETE SET NULL, + created_by UUID REFERENCES auction_users (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS auction_artworks_status_ends_idx + ON auction_artworks (status, ends_at); + +CREATE INDEX IF NOT EXISTS auction_artworks_created_at_idx + ON auction_artworks (created_at DESC); + +-- --------------------------------------------------------------------------- +-- Bid +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS auction_bids ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + artwork_id UUID NOT NULL REFERENCES auction_artworks (id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auction_users (id) ON DELETE CASCADE, + amount NUMERIC(12, 2) NOT NULL CHECK (amount > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS auction_bids_artwork_amount_idx + ON auction_bids (artwork_id, amount DESC, created_at DESC); + +CREATE INDEX IF NOT EXISTS auction_bids_user_created_idx + ON auction_bids (user_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- Notification (email audit / dedupe) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS auction_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auction_users (id) ON DELETE CASCADE, + type TEXT NOT NULL, + artwork_id UUID REFERENCES auction_artworks (id) ON DELETE SET NULL, + bid_id UUID REFERENCES auction_bids (id) ON DELETE SET NULL, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + meta JSONB +); + +CREATE INDEX IF NOT EXISTS auction_notifications_user_type_idx + ON auction_notifications (user_id, type, created_at DESC); + +-- Idempotent "ending soon" (one per user per artwork) +CREATE UNIQUE INDEX IF NOT EXISTS auction_notifications_ending_soon_uidx + ON auction_notifications (type, user_id, artwork_id) + WHERE type = 'auction_ending_soon' AND artwork_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- Login tokens (magic-link / OTP) — Slice 2 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS auction_login_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + token_hash TEXT NOT NULL, + name TEXT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS auction_login_tokens_email_idx + ON auction_login_tokens (lower(email), expires_at DESC); + +CREATE INDEX IF NOT EXISTS auction_login_tokens_hash_idx + ON auction_login_tokens (token_hash) + WHERE used_at IS NULL; + +-- --------------------------------------------------------------------------- +-- Schema migrations bookkeeping +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS auction_schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/docs/auction/ACCEPTANCE.md b/docs/auction/ACCEPTANCE.md index 22dd932..4fc04bd 100644 --- a/docs/auction/ACCEPTANCE.md +++ b/docs/auction/ACCEPTANCE.md @@ -60,6 +60,7 @@ Every MVP feature maps to **Requirement → Implementation → Verification**. | N4 Responsive | Gallery/detail/admin usable at ~375px width | | N5 Secure | Mutating routes require auth; admin gated; bid race test passes; secrets not in client | +## Documentation acceptance (Phase 0) ## Documentation acceptance (this PR) | Check | Verification | @@ -67,5 +68,7 @@ Every MVP feature maps to **Requirement → Implementation → Verification**. | Links | All index links in README resolve under `docs/auction/` | | Scope | Docs only; no application runtime behavior change | | Honesty | CURRENT_STATE separates OBSERVED / INFERRED / UNKNOWN | +| Root README | Points to `docs/auction` under Auction Space / Silent Auction planning | +| Decisions | [DECISIONS.md](./DECISIONS.md) exists with D1–D4 log + env checklist | | Root README | Points to `docs/auction` under Silent Auction MVP (Planning) | | Roadmap | [ROADMAP.md](./ROADMAP.md) covers Phases 0–4; [SLICES.md](./SLICES.md) defines Phase 1 vertical slices to a minimal fully functional auction; [TODO.md](./TODO.md) lists tasks per slice | diff --git a/docs/auction/CURRENT_STATE.md b/docs/auction/CURRENT_STATE.md index a849f1b..a228184 100644 --- a/docs/auction/CURRENT_STATE.md +++ b/docs/auction/CURRENT_STATE.md @@ -17,6 +17,7 @@ Findings are separated into **OBSERVED**, **INFERRED**, and **UNKNOWN**. No spec | Deployment | **OBSERVED:** GitHub Pages deployments (`github-pages` environment); `CNAME` = `hackerdojo.org` | | Serverless API | **OBSERVED:** `api/waitlist.js` — Vercel-style `export default async function handler(req, res)` proxying POST bodies to an Airtable webhook | | Admin UI | **OBSERVED:** No in-repo admin application | +| Docs folder | **OBSERVED:** `docs/auction/` planning package (Auction Space / Silent Auction MVP); no other feature docs trees | | Docs folder | **OBSERVED:** No prior `docs/` tree (this package introduces `docs/auction/`) | | CI workflows | **OBSERVED:** `.github/CODEOWNERS` only; no `.github/workflows/` | @@ -100,4 +101,5 @@ These are reasonable conclusions from observed code, not confirmed configuration - Approved email provider - Confirmed serverless host for `api/` +Planning docs are in place. Coding should wait until those four are recorded in [DECISIONS.md](./DECISIONS.md). Documentation can proceed; coding should wait until those four are decided in review of this PR. diff --git a/docs/auction/DATA_MODEL.md b/docs/auction/DATA_MODEL.md index 359c1d9..fab39f7 100644 --- a/docs/auction/DATA_MODEL.md +++ b/docs/auction/DATA_MODEL.md @@ -21,6 +21,18 @@ Artwork * --- (admin managed by) User(role=admin) Note: Bid field `auction_id` in the brief maps to **Artwork.id** (each artwork listing is an auction lot in MVP). Column name in DB: `artwork_id` (clearer). API may accept `auction_id` as an alias if needed for brief compatibility — prefer `artwork_id` in code. +## Physical table names (Slice 0) + +Implementation uses an `auction_` prefix so the schema can share a Postgres database with other Dojo apps: + +| Logical entity | Table | +|----------------|--------| +| User | `auction_users` | +| Artwork | `auction_artworks` | +| Bid | `auction_bids` | +| Notification | `auction_notifications` | +| Login token (magic-link / OTP) | `auction_login_tokens` | +| Migration bookkeeping | `auction_schema_migrations` | ## Tables ### User (**NEW** — required) diff --git a/docs/auction/DECISIONS.md b/docs/auction/DECISIONS.md new file mode 100644 index 0000000..06e77fb --- /dev/null +++ b/docs/auction/DECISIONS.md @@ -0,0 +1,69 @@ +# Decisions — Auction Space (Phase 0) + +Record infrastructure choices **before** Phase 1 coding. +Until the four decisions below are filled, Slice 0 (Bootstrap) stays blocked. + +Related: [CURRENT_STATE.md](./CURRENT_STATE.md) unknowns · [ARCHITECTURE.md](./ARCHITECTURE.md) · [TODO.md](./TODO.md) T00d / T01 + +--- + +## Decision log + +| ID | Topic | Choice | Decided by | Date | Notes | +|----|-------|--------|------------|------|-------| +| D1 | Database | Hosted Postgres via `DATABASE_URL` (Neon or Supabase-compatible) | Operator (defaults approved) | 2026-08-04 | Transactional row locks for bids (`SELECT … FOR UPDATE`) | +| D2 | Auth method | Email magic-link / OTP + HTTP-only signed session cookie | Operator (defaults approved) | 2026-08-04 | Same flow for bidder + admin; admin via `role=admin` / `ADMIN_EMAIL` seed | +| D3 | Email provider | Resend (`RESEND_API_KEY` + `EMAIL_FROM`) | Operator (defaults approved) | 2026-08-04 | Transactional only (bid / outbid / winner / closed / ending soon) | +| D4 | API host | Vercel serverless (`api/*.js` + `vercel.json`) | Operator (defaults approved) | 2026-08-04 | Same pattern as `api/waitlist.js`; static site remains GitHub Pages | + +### Candidate shortlists (not prescriptions) + +| Topic | Options to consider | +|-------|---------------------| +| D1 Database | Hosted Postgres (preferred for bid integrity); other durable SQL if already operated by Dojo | +| D2 Auth | Magic-link / OTP + session cookie (default in ARCHITECTURE); Nexudus SSO only if UNKNOWN is resolved | +| D3 Email | Resend, Postmark, SendGrid, or existing Dojo transactional account | +| D4 API host | Confirm active Vercel (or compatible) project; check in host config with Slice 5 | + +**Do not** use Airtable as the system of record for bids unless reviewers explicitly accept concurrency/integrity tradeoffs (see CURRENT_STATE). + +--- + +## Env var checklist (fill after D1–D4) + +Copy into the Slice 0 / staging secrets store. Names are suggestions — rename to match the chosen providers. + +| Variable | Purpose | Required from | +|----------|---------|---------------| +| `DATABASE_URL` | Postgres connection string | D1 | +| `SESSION_SECRET` | Sign/encrypt session cookies (≥32 chars) | D2 | +| `ADMIN_EMAIL` | Seed admin user | Slice 0 | +| `RESEND_API_KEY` | Resend API key (`EMAIL_API_KEY` alias also accepted) | D3 | +| `EMAIL_FROM` | From address for auction mail (verified in Resend) | D3 | +| `AUCTION_CRON_SECRET` | Authorize ending-soon cron | Slice 4 | +| `CORS_ORIGIN` | Allowed browser origin(s), e.g. `https://hackerdojo.org` | Slice 5 | +| `SITE_URL` | Absolute site origin for magic links (e.g. `https://hackerdojo.org`) | D2 / Slice 2 | + +Add provider-specific vars when D3 is chosen; keep secrets out of the client and out of git. + +--- + +## How to close T00d + +1. Reviewers fill the Decision log table (D1–D4). +2. Update this file’s “Choice / Decided by / Date” columns. +3. Check off **T00d** in [TODO.md](./TODO.md). +4. Copy confirmed choices + env list into Slice 0 task **T01**. +5. Open the Slice 0 implementation PR. + +**T00d status:** closed 2026-08-04 with operator-approved defaults above. + +--- + +## History + +| Date | Event | +|------|-------| +| 2026-08-03 | Planning docs package opened; upstream [PR #62](https://github.com/hd-admin/hackerdojo.org/pull/62) merged to `hd-admin/hackerdojo.org` | +| 2026-08-04 | Decision log + env checklist added so Phase 0 can finish without blocking on ad-hoc chat | +| 2026-08-04 | D1–D4 recorded (Postgres, magic-link/OTP, Resend, Vercel); Slice 0 unblocked | diff --git a/docs/auction/README.md b/docs/auction/README.md index 0ae8ba7..6ad1b37 100644 --- a/docs/auction/README.md +++ b/docs/auction/README.md @@ -1,3 +1,14 @@ +# Auction Space — Silent Auction MVP Planning + +Documentation-first design for **Auction Space**: Hacker Dojo’s minimal fundraising silent auction. + +**Status:** Phase 0 docs complete on upstream (`hd-admin` [PR #62](https://github.com/hd-admin/hackerdojo.org/pull/62)). Infrastructure choices still open in [DECISIONS.md](./DECISIONS.md). No production auction behavior has shipped. + +## Overview + +Auction Space is a simple silent auction for fundraising — typically artwork and donated items. This is **not** an online marketplace. Bidders browse listings, place bids before a deadline, and receive email updates. Admins manage artworks and close auctions. + +This package defines requirements, architecture, data model, API, UI wireframes, emails, security, acceptance criteria, decisions, and an implementation roadmap **before** application code is written. # Silent Auction MVP — Planning Documentation-first design for a minimal Hacker Dojo fundraising silent auction. @@ -66,6 +77,7 @@ This package defines requirements, architecture, data model, API, UI wireframes, |-----|---------| | [START_HERE.md](./START_HERE.md) | Reading order, implementation order, assumptions, scope | | [SLICES.md](./SLICES.md) | **Build guide:** vertical slices → minimal fully functional auction | +| [DECISIONS.md](./DECISIONS.md) | **Phase 0 gate:** DB / auth / email / API host + env checklist | | [CURRENT_STATE.md](./CURRENT_STATE.md) | Repository audit (observed / inferred / unknown) | | [REQUIREMENTS.md](./REQUIREMENTS.md) | Functional & nonfunctional requirements; out of scope | | [ARCHITECTURE.md](./ARCHITECTURE.md) | Frontend, backend, database, email, auth, deployment | @@ -95,6 +107,7 @@ See **[SLICES.md](./SLICES.md)**. Short version: | Phase | Name | Status | |-------|------|--------| +| 0 | Documentation | Docs package done; **T00d** decisions still open | | 0 | Documentation | In progress (this package / planning PR) | | 1 | MVP (slices 0–5) | Planned — [SLICES.md](./SLICES.md) + [TODO.md](./TODO.md) | | 2 | Payments | After MVP — ROADMAP + TODO T27–T34 | @@ -108,6 +121,11 @@ Details: [ROADMAP.md](./ROADMAP.md). | Item | State | |------|-------| | Repository audit | Complete (see [CURRENT_STATE.md](./CURRENT_STATE.md)) | +| Planning docs | This package (Phases 0–4 + slices + decisions log) | +| Upstream merge | [hd-admin#62](https://github.com/hd-admin/hackerdojo.org/pull/62) merged 2026-08-03 | +| Application code | **Slices 0–4** — full email catalog + ending-soon/auto-close cron | +| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 5 (Ship) | +| Slice 0–4 runbooks | [SLICE_0](./SLICE_0_RUNBOOK.md) · [1](./SLICE_1_RUNBOOK.md) · [2](./SLICE_2_RUNBOOK.md) · [3](./SLICE_3_RUNBOOK.md) · [4](./SLICE_4_RUNBOOK.md) | | Planning docs | This package (includes full Phases 0–4 roadmap) | | Application code | **Unchanged** | | Implementation | Not started — blocked on review of this PR | diff --git a/docs/auction/ROADMAP.md b/docs/auction/ROADMAP.md index 1299ded..17f0eb0 100644 --- a/docs/auction/ROADMAP.md +++ b/docs/auction/ROADMAP.md @@ -1,3 +1,6 @@ +# Roadmap — Auction Space (Silent Auction) + +Full delivery roadmap for Hacker Dojo **Auction Space**. # Roadmap — Silent Auction Full delivery roadmap for the Hacker Dojo Silent Auction fundraising feature. @@ -20,6 +23,7 @@ Task checklist: [TODO.md](./TODO.md) --- +## Phase 0 — Documentation ## Phase 0 — Documentation (this PR) **Goal:** Design and document the MVP so reviewers can approve scope and infrastructure before implementation. @@ -28,6 +32,7 @@ Task checklist: [TODO.md](./TODO.md) - Repository audit ([CURRENT_STATE.md](./CURRENT_STATE.md)) - Requirements, architecture, data model, API, UI wireframes, emails, security, acceptance, tasks +- Decision log ([DECISIONS.md](./DECISIONS.md)) - Root README pointer to `docs/auction` - **No production behavior change** @@ -38,11 +43,14 @@ Task checklist: [TODO.md](./TODO.md) ### Deliverables - Complete `docs/auction/` package (this tree) +- Planning PR for CODEOWNERS review ([hd-admin#62](https://github.com/hd-admin/hackerdojo.org/pull/62) merged) - Draft planning PR for CODEOWNERS review ### Exit criteria - [x] Documentation package committed +- [x] Planning PR reviewed / merged upstream +- [ ] Infrastructure decisions recorded in [DECISIONS.md](./DECISIONS.md): database, auth method, email provider, API host - [ ] Planning PR reviewed - [ ] Infrastructure decisions recorded: database, auth method, email provider, API host - [ ] Phase 1 kickoff approved diff --git a/docs/auction/SLICES.md b/docs/auction/SLICES.md index 0905b4a..184a0a0 100644 --- a/docs/auction/SLICES.md +++ b/docs/auction/SLICES.md @@ -1,8 +1,11 @@ +# Implementation slices — Auction Space (minimal fully functional) # Implementation slices — minimal fully functional auction Build Phase 1 as **thin vertical slices**. Each slice ships something you can demo. Do not start the next slice until the current one’s **Done when** passes. +**Gate:** [DECISIONS.md](./DECISIONS.md) D1–D4 must be filled before Slice 0 coding. + ```text Slice 0 Bootstrap ↓ diff --git a/docs/auction/SLICE_0_RUNBOOK.md b/docs/auction/SLICE_0_RUNBOOK.md new file mode 100644 index 0000000..1915332 --- /dev/null +++ b/docs/auction/SLICE_0_RUNBOOK.md @@ -0,0 +1,70 @@ +# Slice 0 — Bootstrap runbook + +Infrastructure choices are recorded in [DECISIONS.md](./DECISIONS.md). + +## What shipped + +| Piece | Path | +|-------|------| +| Decisions D1–D4 | `docs/auction/DECISIONS.md` | +| Env template | `.env.example` | +| Node deps | `package.json` (`pg`) | +| Vercel config | `vercel.json` | +| SQL migration | `db/migrations/001_auction_init.sql` | +| Migrate / seed scripts | `scripts/auction-migrate.js`, `scripts/auction-seed-admin.js` | +| Shared lib | `lib/auction/*` (db, auth, money, http, errors, users) | +| Health | `GET /api/auction/health` | +| Admin ping | `GET /api/auction/admin/ping` (session + admin required) | + +## One-time setup + +1. Create a Postgres database (Neon or Supabase free tier is fine). +2. Copy env template and fill secrets: + +```bash +cp .env.example .env.local +# edit DATABASE_URL, SESSION_SECRET, ADMIN_EMAIL +openssl rand -hex 32 # paste into SESSION_SECRET +``` + +3. Install and bootstrap: + +```bash +npm install +npm run auction:bootstrap +``` + +This runs migrations then seeds/promotes `ADMIN_EMAIL` to `role=admin`. + +4. Deploy API to Vercel (link this repo; set the same env vars in the project). + Static Jekyll site can stay on GitHub Pages; only `api/*` needs Vercel. + +5. Smoke checks: + +```bash +curl -sS https:///api/auction/health | jq . +# expect: { "ok": true, "schema_version": "001_auction_init", ... } +``` + +Admin ping requires a session cookie (issued in Slice 2). Until then, verify admin row in SQL: + +```sql +SELECT id, email, role FROM auction_users WHERE role = 'admin'; +``` + +## Local API (optional) + +```bash +npx vercel dev +# or: npm i -g vercel && vercel dev +``` + +## Done when (Slice 0) + +- [x] D1–D4 recorded +- [x] Migration applies cleanly +- [x] Admin user can be seeded from `ADMIN_EMAIL` +- [x] Shared helpers exist (JSON, errors, money, requireUser/requireAdmin) +- [x] Health + admin ping routes exist + +**Next:** Slice 1 — browse gallery + artwork detail ([SLICES.md](./SLICES.md)). diff --git a/docs/auction/SLICE_1_RUNBOOK.md b/docs/auction/SLICE_1_RUNBOOK.md new file mode 100644 index 0000000..292ebf4 --- /dev/null +++ b/docs/auction/SLICE_1_RUNBOOK.md @@ -0,0 +1,45 @@ +# Slice 1 — Browse runbook + +Depends on [Slice 0](./SLICE_0_RUNBOOK.md). + +## What shipped + +| Piece | Path | +|-------|------| +| List API | `GET /api/auction/artworks` | +| Detail API | `GET /api/auction/artworks/:id` | +| Serialization | `lib/auction/artworks.js` | +| Demo seed | `npm run auction:seed-demo` | +| Gallery | `/auction/` → `auction/index.html` | +| Detail | `/auction/artwork/?id=` → `auction/artwork.html` | +| Client JS/CSS | `static/js/auction.js`, `static/css/auction.css` | +| Nav link | header → Auction | + +## Local demo + +```bash +cp .env.example .env.local # DATABASE_URL, SESSION_SECRET, ADMIN_EMAIL +npm install +npm run auction:bootstrap # migrate + admin + demo lot + +# Terminal A — API +npx vercel dev +# Terminal B — site +bundle exec jekyll serve +``` + +Open `http://localhost:4000/auction/` (or Jekyll’s port). + +If the API is on another origin, set before loading auction.js: + +```html + +``` + +## Done when + +- [x] Gallery loads lots from API +- [x] Detail shows current/starting bid + ticking countdown +- [x] Bid CTA disabled / “coming soon” (Slice 2 enables place-bid) + +**Next:** Slice 2 — login + place bid. diff --git a/docs/auction/SLICE_2_RUNBOOK.md b/docs/auction/SLICE_2_RUNBOOK.md new file mode 100644 index 0000000..72c61bc --- /dev/null +++ b/docs/auction/SLICE_2_RUNBOOK.md @@ -0,0 +1,43 @@ +# Slice 2 — Bid runbook + +Depends on [Slice 0](./SLICE_0_RUNBOOK.md) + [Slice 1](./SLICE_1_RUNBOOK.md). + +## What shipped + +| Piece | Path | +|-------|------| +| Request OTP | `POST /api/auction/auth/request-link` | +| Verify + session cookie | `POST /api/auction/auth/verify` | +| Session / logout | `GET` / `DELETE /api/auction/auth/session` | +| Me | `GET /api/auction/me` | +| Place bid | `POST /api/auction/bids` (row lock + validation) | +| Email helper | `lib/auction/email.js` (Resend; optional) | +| Login + bid modal | `static/js/auction.js` | + +## Auth flow + +1. User opens **Place bid** on an active lot. +2. If not signed in → email (+ optional name) → 6-digit OTP emailed (or `dev_otp` when Resend is unset). +3. Verify → HTTP-only `hd_auction_session` cookie. +4. Submit amount ≥ minimum next bid → 201 + refreshed lot. + +## Dev without Resend + +Leave `RESEND_API_KEY` empty. The request-link response includes `dev_otp` so you can complete login locally. + +## Smoke checklist + +- [ ] Request code for a real email (or use `dev_otp`) +- [ ] Verify → session shows “Signed in as …” +- [ ] Place minimum bid → current bid updates +- [ ] Lower bid → `BID_TOO_LOW` +- [ ] Second user outbids → previous high (if email configured) gets outbid mail +- [ ] Log out clears session + +## Done when + +- [x] Logged-in user can place a valid bid +- [x] Invalid / late bids fail cleanly +- [x] Bid CTA wired on artwork page + +**Next:** Slice 3 — admin create / edit / close. diff --git a/docs/auction/SLICE_3_RUNBOOK.md b/docs/auction/SLICE_3_RUNBOOK.md new file mode 100644 index 0000000..7712316 --- /dev/null +++ b/docs/auction/SLICE_3_RUNBOOK.md @@ -0,0 +1,38 @@ +# Slice 3 — Admin runbook + +Depends on Slices 0–2. + +## What shipped + +| Piece | Path | +|-------|------| +| Admin list / detail + bids | `GET /api/auction/admin/artworks` (+ `?id=`) | +| Create | `POST /api/auction/artworks` | +| Patch | `PATCH /api/auction/artworks/:id` | +| Delete draft | `DELETE /api/auction/artworks/:id` | +| Close + winner | `POST /api/auction/artworks/:id/close` | +| Admin UI | `/auction/admin/` → `auction/admin.html` + `static/js/auction-admin.js` | + +## Operator flow + +1. Open `/auction/admin/` +2. Log in with **admin** email (`ADMIN_EMAIL` seeded via `npm run auction:seed-admin`) +3. **New artwork** → fill title, artist, images (https URLs), starting bid, increment, ends at +4. Set status `active` (or `preview` then activate later) → **Save** +5. Public gallery `/auction/` shows the lot +6. After bidding, **Close auction** → winner set from high bid; emails if Resend configured + +## Rules + +- Only **draft** lots with zero bids can be deleted. +- Closing is the only way to set `status=closed` + `winner_user_id`. +- `starting_bid` cannot drop below `current_bid` once bids exist. +- Active lots need `ends_at` in the future. + +## Done when + +- [x] Staff can create → activate → close without SQL +- [x] Public gallery reflects status changes +- [x] Bid list visible in admin editor + +**Next:** Slice 4 — polish emails + ending-soon cron (bid/outbid/winner already partially wired). diff --git a/docs/auction/SLICE_4_RUNBOOK.md b/docs/auction/SLICE_4_RUNBOOK.md new file mode 100644 index 0000000..b8e9cb8 --- /dev/null +++ b/docs/auction/SLICE_4_RUNBOOK.md @@ -0,0 +1,63 @@ +# Slice 4 — Emails runbook + +Depends on Slices 0–3. + +## What shipped + +| Type | Trigger | +|------|---------| +| `bid_received` | Successful bid | +| `outbid` | New high bid displaces previous bidder | +| `winner` | Lot closed with bids | +| `auction_closed` | Lot closed → all admins | +| `auction_ending_soon` | Cron: active lot ends within window (default 24h), once per high bidder | +| `login_otp` | Auth request-link | + +| Piece | Path | +|-------|------| +| Templates / send | `lib/auction/email.js` | +| Cron logic | `lib/auction/cron.js` | +| Cron HTTP | `GET|POST /api/auction/cron/ending-soon` | +| Schedule | `vercel.json` crons — hourly | + +Cron also **auto-closes** active lots with `ends_at <= now()` (winner emails included). + +## Secrets + +```bash +AUCTION_CRON_SECRET=$(openssl rand -hex 24) +RESEND_API_KEY=re_… +EMAIL_FROM="Hacker Dojo Auction " +``` + +Vercel Cron may send without your secret header on some plans — if so, call the route from an external scheduler: + +```bash +curl -X POST "https:///api/auction/cron/ending-soon" \ + -H "Authorization: Bearer $AUCTION_CRON_SECRET" +``` + +If `AUCTION_CRON_SECRET` is unset, the route returns a config error (fail closed). + +## Idempotency + +`auction_ending_soon` uses a partial unique index on `(type, user_id, artwork_id)` plus pre-check so re-runs do not spam. + +## Manual test + +```bash +# With DB + secrets loaded +curl -sS -X POST "http://localhost:3000/api/auction/cron/ending-soon" \ + -H "Authorization: Bearer $AUCTION_CRON_SECRET" | jq . +``` + +Expect `ending_soon` and `auto_close` summary objects. + +## Done when + +- [x] bid_received + outbid from bid handler +- [x] winner + auction_closed on close (all admins) +- [x] ending-soon cron + auto-close past end +- [x] Notification rows written; failures do not roll back bids + +**Next:** Slice 5 — CORS/CSRF hardening, rate limits, staging acceptance, operator runbook. diff --git a/docs/auction/START_HERE.md b/docs/auction/START_HERE.md index 843e0e1..d57299c 100644 --- a/docs/auction/START_HERE.md +++ b/docs/auction/START_HERE.md @@ -6,6 +6,22 @@ 2. [CURRENT_STATE.md](./CURRENT_STATE.md) — what the repo actually is today 3. [REQUIREMENTS.md](./REQUIREMENTS.md) — what the MVP must do 4. [SLICES.md](./SLICES.md) — **how to build** (easy vertical slices → minimal fully functional auction) +5. [DECISIONS.md](./DECISIONS.md) — **fill before coding** (DB, auth, email, API host) +6. [ARCHITECTURE.md](./ARCHITECTURE.md) — how it fits this repo +7. [DATA_MODEL.md](./DATA_MODEL.md) — tables and relationships +8. [API.md](./API.md) — REST surface +9. [UI.md](./UI.md) — page wireframes +10. [EMAILS.md](./EMAILS.md) — notification catalog +11. [SECURITY.md](./SECURITY.md) — threats and controls +12. [ACCEPTANCE.md](./ACCEPTANCE.md) — how we know it works +13. [ROADMAP.md](./ROADMAP.md) — phases 0–4 +14. [TODO.md](./TODO.md) — task checklist by slice + +## How to implement (after Phase 0 decisions) + +1. Record DB / auth / email / API host in [DECISIONS.md](./DECISIONS.md) (closes T00d). +2. Build **only** [SLICES.md](./SLICES.md) **0 → 5**, in order. +3. After Slice 5, stop — that is the **minimal fully functional** Auction Space. 5. [ARCHITECTURE.md](./ARCHITECTURE.md) — how it fits this repo 6. [DATA_MODEL.md](./DATA_MODEL.md) — tables and relationships 7. [API.md](./API.md) — REST surface diff --git a/docs/auction/TODO.md b/docs/auction/TODO.md index ed01036..4c73895 100644 --- a/docs/auction/TODO.md +++ b/docs/auction/TODO.md @@ -3,6 +3,7 @@ Build guide: **[SLICES.md](./SLICES.md)** (read this first for Phase 1). Roadmap: [ROADMAP.md](./ROADMAP.md). +Do not start Phase 1 coding until [DECISIONS.md](./DECISIONS.md) records DB / auth / email / API host choices. Do not start Phase 1 coding until Phase 0 review records DB / auth / email / API host choices. --- @@ -11,6 +12,10 @@ Do not start Phase 1 coding until Phase 0 review records DB / auth / email / API - [x] **T00a** Repository audit → CURRENT_STATE.md - [x] **T00b** Planning docs package under `docs/auction/` +- [x] **T00c** Root README pointer + planning PR ([hd-admin#62](https://github.com/hd-admin/hackerdojo.org/pull/62)) +- [x] **T00c2** Decision log + env checklist → [DECISIONS.md](./DECISIONS.md) +- [x] **T00d** Reviewer records DB / auth / email / API host decisions in DECISIONS.md (defaults 2026-08-04) +- [x] **T00e** Upstream Phase 0 docs merged; open Phase 1 PRs **one slice at a time** after T00d - [x] **T00c** Root README pointer + draft planning PR - [ ] **T00d** Reviewer records DB / auth / email / API host decisions - [ ] **T00e** Merge Phase 0 docs; open Phase 1 implementation PRs **one slice at a time** @@ -23,6 +28,20 @@ Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.m ### Slice 0 — Bootstrap +- [x] **T01** Copy approved choices from [DECISIONS.md](./DECISIONS.md) into Slice 0 PR; confirm env vars present in staging. +- [x] **T02** Add DB client + migration tooling; empty migration pipeline runs. +- [x] **T03** Migrate `User` + seed one admin email from env. +- [x] **T04** Migrate `Artwork`, `Bid`, `Notification` + indexes ([DATA_MODEL.md](./DATA_MODEL.md)). +- [x] **T05** Shared API helpers: JSON, error codes, money parse/validate, requireUser / requireAdmin. + +**Slice 0 done when:** migrations apply; admin user exists. → see [SLICE_0_RUNBOOK.md](./SLICE_0_RUNBOOK.md) + +### Slice 1 — Browse + +- [x] **T09** `GET /api/auction/artworks` + `GET /api/auction/artworks/:id` (+ bid amounts). +- [x] **T09b** Seed one `active` artwork for local/staging demos. +- [x] **T10** Jekyll gallery page `/auction/` wired to list API. +- [x] **T11** Artwork detail page + countdown from `ends_at` (bid CTA disabled or “coming next”). - [ ] **T01** Record approved choices: database, email provider, auth method, serverless host; env var checklist. - [ ] **T02** Add DB client + migration tooling; empty migration pipeline runs. - [ ] **T03** Migrate `User` + seed one admin email from env. @@ -42,6 +61,31 @@ Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.m ### Slice 2 — Bid +- [x] **T06** `POST /api/auction/auth/request-link` + token persistence + rate limit. +- [x] **T07** `POST /api/auction/auth/verify` + session cookie + `GET /me` + `DELETE` session. +- [x] **T08** Minimal login UI (reused by bid modal). +- [x] **T12** `POST /api/auction/bids` with transactional row lock + validation errors. +- [x] **T13** Bid modal UI + success/error + refresh current bid on page. +- [ ] **T14** Manual concurrency check (two near-simultaneous bids) — operator smoke on staging. + +**Slice 2 done when:** logged-in user can place a valid bid; invalid/late bids fail cleanly. → [SLICE_2_RUNBOOK.md](./SLICE_2_RUNBOOK.md) + +### Slice 3 — Admin + +- [x] **T19** Admin list/create API (`GET`/`POST` artworks). +- [x] **T20** Admin patch + delete-draft + close (sets winner). +- [x] **T21** Admin HTML page: table, editor form, bid list. + +**Slice 3 done when:** staff can create → activate → close a lot without DB access. → [SLICE_3_RUNBOOK.md](./SLICE_3_RUNBOOK.md) + +### Slice 4 — Emails + +- [x] **T15** Email send helper + `Notification` write on success/failure. +- [x] **T16** `bid_received` + `outbid` from bid handler. +- [x] **T17** `winner` + `auction_closed` on close. +- [x] **T18** Secured cron for `auction_ending_soon` + idempotency (+ auto-close expired). + +**Slice 4 done when:** emails in [EMAILS.md](./EMAILS.md) send for the happy path. → [SLICE_4_RUNBOOK.md](./SLICE_4_RUNBOOK.md) - [ ] **T06** `POST /api/auction/auth/request-link` + token persistence + rate limit. - [ ] **T07** `POST /api/auction/auth/verify` + session cookie + `GET /me` + `DELETE` session. - [ ] **T08** Minimal login UI (reused by bid modal). diff --git a/lib/auction/admin-artworks.js b/lib/auction/admin-artworks.js new file mode 100644 index 0000000..acc0523 --- /dev/null +++ b/lib/auction/admin-artworks.js @@ -0,0 +1,414 @@ +/** + * Admin artwork CRUD + close (sets winner). + */ + +import { query, withTransaction } from './db.js'; +import { parseMoney, roundMoney } from './money.js'; +import { apiError, ErrorCodes } from './errors.js'; +import { + serializeArtworkDetail, + getArtworkById, + bidderDisplay, +} from './artworks.js'; +import { + notifyWinner, + notifyAuctionClosed, + listAdminRecipients, +} from './email.js'; + +const STATUSES = new Set(['draft', 'preview', 'active', 'closed']); + +/** + * @param {unknown} images + * @returns {string[]} + */ +function parseImages(images) { + if (images == null) return []; + let list = images; + if (typeof images === 'string') { + list = images + .split(/\n|,/) + .map((s) => s.trim()) + .filter(Boolean); + } + if (!Array.isArray(list)) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'images must be an array or newline-separated URLs'); + } + return list.map(String).map((u) => u.trim()).filter(Boolean).map((url) => { + if (!/^https:\/\//i.test(url)) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Image URLs must use https://'); + } + return url; + }); +} + +/** + * @param {unknown} value + * @param {string} field + */ +function requireText(value, field, max = 500) { + const s = String(value ?? '').trim(); + if (!s) throw apiError(ErrorCodes.VALIDATION_ERROR, `${field} is required`); + if (s.length > max) { + throw apiError(ErrorCodes.VALIDATION_ERROR, `${field} is too long`); + } + return s; +} + +/** + * Parse money that allows zero (starting bid). + * @param {unknown} value + * @param {string} field + */ +function parseMoneyAllowZero(value, field) { + if (value == null || value === '') { + throw apiError(ErrorCodes.INVALID_AMOUNT, `${field} is required`); + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || value < 0) { + throw apiError(ErrorCodes.INVALID_AMOUNT, `${field} must be >= 0`); + } + return roundMoney(value); + } + const raw = String(value).trim().replace(/[$,]/g, ''); + if (!/^\d+(\.\d{1,2})?$/.test(raw)) { + throw apiError(ErrorCodes.INVALID_AMOUNT, `${field} must be a valid money amount`); + } + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) { + throw apiError(ErrorCodes.INVALID_AMOUNT, `${field} must be >= 0`); + } + return roundMoney(n); +} + +/** + * @param {unknown} value + */ +function parseEndsAt(value) { + if (!value) throw apiError(ErrorCodes.VALIDATION_ERROR, 'ends_at is required'); + const d = new Date(String(value)); + if (!Number.isFinite(d.getTime())) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'ends_at must be a valid ISO datetime'); + } + return d; +} + +/** + * Admin list — all statuses. + */ +export async function listAdminArtworks() { + const { rows } = await query( + `SELECT id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_by, + created_at, updated_at + FROM auction_artworks + ORDER BY updated_at DESC + LIMIT 200` + ); + return rows.map(serializeArtworkDetail); +} + +/** + * @param {string} artworkId + */ +export async function listAdminBids(artworkId) { + const { rows } = await query( + `SELECT b.id, b.amount, b.created_at, u.id AS user_id, u.email, u.name + FROM auction_bids b + JOIN auction_users u ON u.id = b.user_id + WHERE b.artwork_id = $1 + ORDER BY b.amount DESC, b.created_at DESC + LIMIT 200`, + [artworkId] + ); + return rows.map((b) => ({ + id: b.id, + amount: formatMoney(b.amount), + created_at: b.created_at instanceof Date ? b.created_at.toISOString() : b.created_at, + user_id: b.user_id, + email: b.email, + name: b.name, + bidder_display: bidderDisplay(b), + })); +} + +/** + * @param {Record} body + * @param {string} adminUserId + */ +export async function createArtwork(body, adminUserId) { + const title = requireText(body.title, 'title', 200); + const artist = requireText(body.artist, 'artist', 200); + const description = String(body.description ?? '').trim().slice(0, 5000); + const images = parseImages(body.images); + const starting = parseMoneyAllowZero(body.starting_bid, 'starting_bid'); + const increment = parseMoney(body.minimum_increment ?? '5.00'); + const endsAt = parseEndsAt(body.ends_at); + const status = String(body.status || 'draft').toLowerCase(); + if (!STATUSES.has(status)) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Invalid status'); + } + if (status === 'active' && endsAt.getTime() <= Date.now()) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Active lots need ends_at in the future'); + } + if (status === 'closed') { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Create as draft/preview/active; use close endpoint to close'); + } + + const { rows } = await query( + `INSERT INTO auction_artworks ( + title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, created_by, updated_at + ) VALUES ( + $1, $2, $3, $4::jsonb, + $5, NULL, $6, + $7, $8, $9, now() + ) + RETURNING id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_by, + created_at, updated_at`, + [ + title, + artist, + description, + JSON.stringify(images), + starting, + increment, + endsAt.toISOString(), + status, + adminUserId, + ] + ); + + console.info('[auction/admin] created', { artwork_id: rows[0].id, admin: adminUserId }); + return serializeArtworkDetail(rows[0]); +} + +/** + * @param {string} id + * @param {Record} body + * @param {string} adminUserId + */ +export async function patchArtwork(id, body, adminUserId) { + const existing = await getArtworkById(id); + + const title = + body.title !== undefined ? requireText(body.title, 'title', 200) : existing.title; + const artist = + body.artist !== undefined ? requireText(body.artist, 'artist', 200) : existing.artist; + const description = + body.description !== undefined + ? String(body.description ?? '').trim().slice(0, 5000) + : existing.description; + /** @type {string[]} */ + let images; + if (body.images !== undefined) { + images = parseImages(body.images); + } else if (Array.isArray(existing.images)) { + images = existing.images.map(String); + } else if (typeof existing.images === 'string') { + try { + images = JSON.parse(existing.images); + } catch { + images = []; + } + } else { + images = []; + } + + let starting = + body.starting_bid !== undefined + ? parseMoneyAllowZero(body.starting_bid, 'starting_bid') + : Number(existing.starting_bid); + const increment = + body.minimum_increment !== undefined + ? parseMoney(body.minimum_increment) + : Number(existing.minimum_increment); + const endsAt = + body.ends_at !== undefined ? parseEndsAt(body.ends_at) : new Date(existing.ends_at); + const status = + body.status !== undefined + ? String(body.status).toLowerCase() + : existing.status; + + if (!STATUSES.has(status)) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Invalid status'); + } + if (status === 'closed' && existing.status !== 'closed') { + throw apiError( + ErrorCodes.VALIDATION_ERROR, + 'Use POST .../close to close a lot and set the winner' + ); + } + + const currentBid = + existing.current_bid == null || existing.current_bid === '' + ? null + : Number(existing.current_bid); + if (currentBid != null && starting < currentBid) { + throw apiError( + ErrorCodes.VALIDATION_ERROR, + 'starting_bid cannot be below current_bid after bids exist' + ); + } + if (status === 'active' && endsAt.getTime() <= Date.now()) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Active lots need ends_at in the future'); + } + + const { rows } = await query( + `UPDATE auction_artworks SET + title = $2, + artist = $3, + description = $4, + images = $5::jsonb, + starting_bid = $6, + minimum_increment = $7, + ends_at = $8, + status = $9, + updated_at = now() + WHERE id = $1 + RETURNING id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_by, + created_at, updated_at`, + [ + id, + title, + artist, + description, + JSON.stringify(images), + starting, + increment, + endsAt.toISOString(), + status, + ] + ); + + console.info('[auction/admin] patched', { artwork_id: id, admin: adminUserId, status }); + return serializeArtworkDetail(rows[0]); +} + +/** + * @param {string} id + * @param {string} adminUserId + */ +export async function deleteDraftArtwork(id, adminUserId) { + const existing = await getArtworkById(id); + if (existing.status !== 'draft') { + throw apiError(ErrorCodes.ARTWORK_NOT_DELETABLE, 'Only draft artworks can be deleted'); + } + const bids = await query( + `SELECT count(*)::int AS c FROM auction_bids WHERE artwork_id = $1`, + [id] + ); + if ((bids.rows[0]?.c || 0) > 0) { + throw apiError(ErrorCodes.ARTWORK_NOT_DELETABLE, 'Artwork has bids and cannot be deleted'); + } + await query(`DELETE FROM auction_artworks WHERE id = $1`, [id]); + console.info('[auction/admin] deleted draft', { artwork_id: id, admin: adminUserId }); + return { ok: true }; +} + +/** + * Close lot; set winner from high bid; send winner + closed emails. + * @param {string} id + * @param {{ id: string, email: string }} admin + */ +export async function closeArtwork(id, admin) { + const result = await withTransaction(async (client) => { + const locked = await client.query( + `SELECT * FROM auction_artworks WHERE id = $1 FOR UPDATE`, + [id] + ); + const art = locked.rows[0]; + if (!art) throw apiError(ErrorCodes.NOT_FOUND, 'Artwork not found'); + if (art.status === 'closed') { + return { artwork: art, winner: null, highBid: null, alreadyClosed: true }; + } + + const high = await client.query( + `SELECT b.id, b.amount, b.user_id, u.email, u.name + FROM auction_bids b + JOIN auction_users u ON u.id = b.user_id + WHERE b.artwork_id = $1 + ORDER BY b.amount DESC, b.created_at ASC + LIMIT 1`, + [id] + ); + const highBid = high.rows[0] || null; + const winnerId = highBid ? highBid.user_id : null; + + const updated = await client.query( + `UPDATE auction_artworks + SET status = 'closed', + winner_user_id = $2, + updated_at = now() + WHERE id = $1 + RETURNING *`, + [id, winnerId] + ); + + return { + artwork: updated.rows[0], + winner: highBid, + highBid, + alreadyClosed: false, + }; + }); + + const serialized = serializeArtworkDetail(result.artwork); + + if (!result.alreadyClosed) { + const bidCountRes = await query( + `SELECT count(*)::int AS c FROM auction_bids WHERE artwork_id = $1`, + [id] + ); + const bidCount = bidCountRes.rows[0]?.c ?? 0; + + if (result.winner?.email) { + try { + await notifyWinner({ + userId: result.winner.user_id, + to: result.winner.email, + name: result.winner.name, + art: result.artwork, + winningAmount: result.winner.amount, + bidId: result.winner.id, + }); + } catch (err) { + console.error('[auction/admin] winner email error', err); + } + } + + try { + const admins = await listAdminRecipients(); + const recipients = + admins.length > 0 + ? admins + : [{ id: admin.id, email: admin.email, name: null }]; + for (const a of recipients) { + await notifyAuctionClosed({ + userId: a.id, + to: a.email, + art: result.artwork, + winnerEmail: result.winner?.email || null, + winningAmount: result.winner?.amount ?? null, + bidCount, + }); + } + } catch (err) { + console.error('[auction/admin] closed email error', err); + } + + console.info('[auction/admin] closed', { + artwork_id: id, + admin: admin.id, + winner_user_id: result.artwork.winner_user_id, + }); + } + + return serialized; +} diff --git a/lib/auction/artworks.js b/lib/auction/artworks.js new file mode 100644 index 0000000..ab04ac8 --- /dev/null +++ b/lib/auction/artworks.js @@ -0,0 +1,178 @@ +/** + * Artwork query + serialization helpers. + */ + +import { query } from './db.js'; +import { formatMoney, minimumNextBid } from './money.js'; +import { apiError, ErrorCodes } from './errors.js'; + +/** + * @param {unknown} images + * @returns {string[]} + */ +function normalizeImages(images) { + if (Array.isArray(images)) { + return images.map(String).filter(Boolean); + } + if (typeof images === 'string') { + try { + const parsed = JSON.parse(images); + if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean); + } catch { + /* ignore */ + } + } + return []; +} + +/** + * Public list card shape. + * @param {Record} row + */ +export function serializeArtworkListItem(row) { + const images = normalizeImages(row.images); + const starting = Number(row.starting_bid); + const current = + row.current_bid == null || row.current_bid === '' ? null : Number(row.current_bid); + const increment = Number(row.minimum_increment); + const minNext = minimumNextBid({ + starting_bid: starting, + current_bid: current, + minimum_increment: increment, + }); + + return { + id: row.id, + title: row.title, + artist: row.artist, + primary_image: images[0] || null, + starting_bid: formatMoney(starting), + current_bid: formatMoney(current), + minimum_increment: formatMoney(increment), + minimum_next_bid: formatMoney(minNext), + ends_at: row.ends_at instanceof Date ? row.ends_at.toISOString() : row.ends_at, + status: row.status, + }; +} + +/** + * Detail shape. + * @param {Record} row + */ +export function serializeArtworkDetail(row) { + const base = serializeArtworkListItem(row); + return { + ...base, + description: row.description ?? '', + images: normalizeImages(row.images), + winner_user_id: row.winner_user_id ?? null, + }; +} + +/** + * Privacy-safe bidder display (first name or masked email local-part). + * @param {{ name?: string | null, email?: string | null }} user + */ +export function bidderDisplay(user) { + if (user?.name && String(user.name).trim()) { + const first = String(user.name).trim().split(/\s+/)[0]; + return first; + } + if (user?.email) { + const local = String(user.email).split('@')[0] || 'bidder'; + if (local.length <= 2) return `${local[0] || 'b'}…`; + return `${local.slice(0, 2)}…`; + } + return 'Bidder'; +} + +/** + * @param {{ status?: string, limit?: number, offset?: number, includePreview?: boolean }} opts + */ +export async function listPublicArtworks(opts = {}) { + const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 100); + const offset = Math.max(Number(opts.offset) || 0, 0); + const status = (opts.status || 'active').toLowerCase(); + + /** @type {string[]} */ + let statuses; + if (status === 'all_public') { + statuses = ['preview', 'active', 'closed']; + } else if (['preview', 'active', 'closed'].includes(status)) { + statuses = [status]; + } else { + statuses = ['active']; + } + + const { rows } = await query( + `SELECT id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_at, updated_at + FROM auction_artworks + WHERE status = ANY($1::text[]) + ORDER BY + CASE status + WHEN 'active' THEN 0 + WHEN 'preview' THEN 1 + WHEN 'closed' THEN 2 + ELSE 3 + END, + ends_at ASC + LIMIT $2 OFFSET $3`, + [statuses, limit, offset] + ); + + return rows.map(serializeArtworkListItem); +} + +/** + * @param {string} id + */ +export async function getArtworkById(id) { + if (!id || !/^[0-9a-f-]{36}$/i.test(id)) { + throw apiError(ErrorCodes.NOT_FOUND, 'Artwork not found'); + } + const { rows } = await query( + `SELECT id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_by, + created_at, updated_at + FROM auction_artworks + WHERE id = $1 + LIMIT 1`, + [id] + ); + if (!rows[0]) { + throw apiError(ErrorCodes.NOT_FOUND, 'Artwork not found'); + } + return rows[0]; +} + +/** + * Public detail: hide drafts. + * @param {string} id + */ +export async function getPublicArtworkDetail(id) { + const row = await getArtworkById(id); + if (row.status === 'draft') { + throw apiError(ErrorCodes.NOT_FOUND, 'Artwork not found'); + } + const { rows: bidRows } = await query( + `SELECT b.amount, b.created_at, u.name, u.email + FROM auction_bids b + JOIN auction_users u ON u.id = b.user_id + WHERE b.artwork_id = $1 + ORDER BY b.amount DESC, b.created_at DESC + LIMIT 50`, + [id] + ); + + return { + artwork: serializeArtworkDetail(row), + bids: bidRows.map((b) => ({ + amount: formatMoney(b.amount), + created_at: b.created_at instanceof Date ? b.created_at.toISOString() : b.created_at, + bidder_display: bidderDisplay(b), + })), + }; +} diff --git a/lib/auction/auth.js b/lib/auction/auth.js new file mode 100644 index 0000000..f63a945 --- /dev/null +++ b/lib/auction/auth.js @@ -0,0 +1,167 @@ +/** + * Session cookie auth (HMAC-signed payload). + * Magic-link issue/verify lands in Slice 2; guards are ready for Slice 0+. + */ + +import crypto from 'node:crypto'; +import { query } from './db.js'; +import { + getSessionSecret, + SESSION_COOKIE, + SESSION_MAX_AGE_SECONDS, +} from './config.js'; +import { apiError, ErrorCodes } from './errors.js'; +import { parseCookies, setCookie, clearCookie } from './http.js'; + +/** + * @typedef {{ id: string, email: string, name: string | null, role: 'bidder' | 'admin' }} AuctionUser + */ + +/** + * @param {string} payload + * @param {string} secret + */ +function sign(payload, secret) { + return crypto.createHmac('sha256', secret).update(payload).digest('base64url'); +} + +/** + * Create a signed session token for a user id. + * @param {string} userId + * @param {number} [maxAgeSeconds] + */ +export function createSessionToken(userId, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) { + const secret = getSessionSecret(); + const exp = Math.floor(Date.now() / 1000) + maxAgeSeconds; + const payload = `${userId}.${exp}`; + const sig = sign(payload, secret); + return `${payload}.${sig}`; +} + +/** + * @param {string | undefined} token + * @returns {{ userId: string, exp: number } | null} + */ +export function verifySessionToken(token) { + if (!token) return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [userId, expStr, sig] = parts; + if (!userId || !expStr || !sig) return null; + const secret = getSessionSecret(); + const payload = `${userId}.${expStr}`; + const expected = sign(payload, secret); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; + const exp = Number(expStr); + if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null; + return { userId, exp }; +} + +/** + * @param {string} userId + * @returns {Promise} + */ +export async function getUserById(userId) { + const { rows } = await query( + `SELECT id, email, name, role + FROM auction_users + WHERE id = $1 + LIMIT 1`, + [userId] + ); + if (!rows[0]) return null; + return { + id: rows[0].id, + email: rows[0].email, + name: rows[0].name, + role: rows[0].role, + }; +} + +/** + * @param {string} email + * @returns {Promise} + */ +export async function getUserByEmail(email) { + const { rows } = await query( + `SELECT id, email, name, role + FROM auction_users + WHERE lower(email) = lower($1) + LIMIT 1`, + [email] + ); + if (!rows[0]) return null; + return { + id: rows[0].id, + email: rows[0].email, + name: rows[0].name, + role: rows[0].role, + }; +} + +/** + * @param {import('http').IncomingMessage} req + * @returns {Promise} + */ +export async function getSessionUser(req) { + const cookies = parseCookies(req); + const token = cookies[SESSION_COOKIE]; + const verified = verifySessionToken(token); + if (!verified) return null; + return getUserById(verified.userId); +} + +/** + * @param {import('http').IncomingMessage} req + * @returns {Promise} + */ +export async function requireUser(req) { + const user = await getSessionUser(req); + if (!user) { + throw apiError(ErrorCodes.UNAUTHENTICATED, 'Login required'); + } + return user; +} + +/** + * @param {import('http').IncomingMessage} req + * @returns {Promise} + */ +export async function requireAdmin(req) { + const user = await requireUser(req); + if (user.role !== 'admin') { + throw apiError(ErrorCodes.FORBIDDEN, 'Admin access required'); + } + return user; +} + +/** + * @param {import('http').ServerResponse} res + * @param {string} userId + */ +export function attachSessionCookie(res, userId) { + const token = createSessionToken(userId); + setCookie(res, SESSION_COOKIE, token, { + maxAge: SESSION_MAX_AGE_SECONDS, + httpOnly: true, + sameSite: 'Lax', + path: '/', + }); +} + +/** + * @param {import('http').ServerResponse} res + */ +export function clearSessionCookie(res) { + clearCookie(res, SESSION_COOKIE); +} + +/** + * Hash a raw login token for storage. + * @param {string} rawToken + */ +export function hashToken(rawToken) { + return crypto.createHash('sha256').update(rawToken).digest('hex'); +} diff --git a/lib/auction/bids.js b/lib/auction/bids.js new file mode 100644 index 0000000..0d48255 --- /dev/null +++ b/lib/auction/bids.js @@ -0,0 +1,146 @@ +/** + * Place bid with transactional row lock. + */ + +import { withTransaction } from './db.js'; +import { + parseMoney, + formatMoney, + assertBidMeetsMinimum, + roundMoney, +} from './money.js'; +import { apiError, ErrorCodes } from './errors.js'; +import { serializeArtworkDetail } from './artworks.js'; +import { notifyBidReceived, notifyOutbid } from './email.js'; + +/** + * @param {{ artworkId: string, userId: string, amount: unknown, userEmail: string, userName?: string | null }} input + */ +export async function placeBid(input) { + const amount = parseMoney(input.amount); + const artworkId = input.artworkId; + if (!artworkId || !/^[0-9a-f-]{36}$/i.test(artworkId)) { + throw apiError(ErrorCodes.NOT_FOUND, 'Artwork not found'); + } + + const result = await withTransaction(async (client) => { + const locked = await client.query( + `SELECT id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_by, + created_at, updated_at + FROM auction_artworks + WHERE id = $1 + FOR UPDATE`, + [artworkId] + ); + const art = locked.rows[0]; + if (!art) { + throw apiError(ErrorCodes.NOT_FOUND, 'Artwork not found'); + } + if (art.status !== 'active') { + throw apiError(ErrorCodes.AUCTION_NOT_ACTIVE, 'Auction is not open for bidding'); + } + const endsAt = new Date(art.ends_at).getTime(); + if (!Number.isFinite(endsAt) || endsAt <= Date.now()) { + throw apiError(ErrorCodes.AUCTION_CLOSED, 'Auction has ended'); + } + + const minNext = minimumNextBid({ + starting_bid: art.starting_bid, + current_bid: art.current_bid, + minimum_increment: art.minimum_increment, + }); + assertBidMeetsMinimum(amount, minNext); + + // Previous high bidder (for outbid email) — before insert + const prev = await client.query( + `SELECT b.id, b.user_id, b.amount, u.email, u.name + FROM auction_bids b + JOIN auction_users u ON u.id = b.user_id + WHERE b.artwork_id = $1 + ORDER BY b.amount DESC, b.created_at DESC + LIMIT 1`, + [artworkId] + ); + const previousHigh = prev.rows[0] || null; + + const inserted = await client.query( + `INSERT INTO auction_bids (artwork_id, user_id, amount) + VALUES ($1, $2, $3) + RETURNING id, artwork_id, user_id, amount, created_at`, + [artworkId, input.userId, amount] + ); + const bid = inserted.rows[0]; + + const updated = await client.query( + `UPDATE auction_artworks + SET current_bid = $2, + updated_at = now() + WHERE id = $1 + RETURNING id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id, created_by, + created_at, updated_at`, + [artworkId, amount] + ); + + return { + bid, + artwork: updated.rows[0], + previousHigh, + }; + }); + + // Emails after commit (do not fail the bid if mail fails) + try { + await notifyBidReceived({ + userId: input.userId, + to: input.userEmail, + name: input.userName, + art: result.artwork, + bid: result.bid, + }); + } catch (err) { + console.error('[auction/bids] bid_received email error', err); + } + + if ( + result.previousHigh && + result.previousHigh.user_id !== input.userId && + result.previousHigh.email + ) { + try { + await notifyOutbid({ + userId: result.previousHigh.user_id, + to: result.previousHigh.email, + name: result.previousHigh.name, + art: result.artwork, + yourAmount: result.previousHigh.amount, + bidId: result.bid.id, + }); + } catch (err) { + console.error('[auction/bids] outbid email error', err); + } + } + + console.info('[auction/bids] accepted', { + artwork_id: result.artwork.id, + bid_id: result.bid.id, + user_id: input.userId, + amount: roundMoney(amount), + }); + + return { + bid: { + id: result.bid.id, + artwork_id: result.bid.artwork_id, + amount: formatMoney(result.bid.amount), + created_at: + result.bid.created_at instanceof Date + ? result.bid.created_at.toISOString() + : result.bid.created_at, + }, + artwork: serializeArtworkDetail(result.artwork), + }; +} diff --git a/lib/auction/config.js b/lib/auction/config.js new file mode 100644 index 0000000..d257156 --- /dev/null +++ b/lib/auction/config.js @@ -0,0 +1,70 @@ +/** + * Auction Space env config (server-only). + * Do not import this from client-side / Jekyll assets. + */ + +function required(name, value) { + if (!value || String(value).trim() === '') { + const err = new Error(`Missing required env: ${name}`); + err.code = 'CONFIG_ERROR'; + throw err; + } + return String(value).trim(); +} + +function optional(value, fallback = '') { + if (value == null || String(value).trim() === '') return fallback; + return String(value).trim(); +} + +export function getDatabaseUrl() { + return required('DATABASE_URL', process.env.DATABASE_URL); +} + +export function getSessionSecret() { + const secret = required('SESSION_SECRET', process.env.SESSION_SECRET); + if (secret.length < 32) { + const err = new Error('SESSION_SECRET must be at least 32 characters'); + err.code = 'CONFIG_ERROR'; + throw err; + } + return secret; +} + +export function getAdminEmail() { + return required('ADMIN_EMAIL', process.env.ADMIN_EMAIL).toLowerCase(); +} + +export function getAdminName() { + return optional(process.env.ADMIN_NAME, 'Auction Admin'); +} + +export function getEmailApiKey() { + return optional(process.env.RESEND_API_KEY || process.env.EMAIL_API_KEY, ''); +} + +export function getEmailFrom() { + return optional(process.env.EMAIL_FROM, ''); +} + +export function getSiteUrl() { + return optional(process.env.SITE_URL, 'https://hackerdojo.org').replace(/\/$/, ''); +} + +export function getCorsOrigins() { + const raw = optional(process.env.CORS_ORIGIN, 'https://hackerdojo.org'); + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +export function getCronSecret() { + return optional(process.env.AUCTION_CRON_SECRET, ''); +} + +/** Cookie name for auction session */ +export const SESSION_COOKIE = 'hd_auction_session'; + +/** Session TTL: 14 days */ +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 14; diff --git a/lib/auction/cron.js b/lib/auction/cron.js new file mode 100644 index 0000000..cf7c3b1 --- /dev/null +++ b/lib/auction/cron.js @@ -0,0 +1,137 @@ +/** + * Auction cron jobs: ending-soon notices + auto-close expired lots. + */ + +import { query } from './db.js'; +import { notifyEndingSoon } from './email.js'; +import { closeArtwork } from './admin-artworks.js'; +import { listAdminRecipients } from './email.js'; + +/** Default: lots ending within 24 hours */ +const ENDING_SOON_HOURS = Number(process.env.AUCTION_ENDING_SOON_HOURS || 24); + +/** + * Send auction_ending_soon once per high bidder (idempotent). + * @param {{ notifyAllBidders?: boolean }} [opts] + */ +export async function runEndingSoon(opts = {}) { + const hours = + Number.isFinite(ENDING_SOON_HOURS) && ENDING_SOON_HOURS > 0 + ? ENDING_SOON_HOURS + : 24; + + const { rows: lots } = await query( + `SELECT id, title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, winner_user_id + FROM auction_artworks + WHERE status = 'active' + AND ends_at > now() + AND ends_at <= now() + ($1::text || ' hours')::interval`, + [String(hours)] + ); + + let sent = 0; + let skipped = 0; + let errors = 0; + + for (const art of lots) { + /** @type {{ user_id: string, email: string, name: string | null }[]} */ + let recipients = []; + + if (opts.notifyAllBidders) { + const r = await query( + `SELECT DISTINCT ON (u.id) u.id AS user_id, u.email, u.name + FROM auction_bids b + JOIN auction_users u ON u.id = b.user_id + WHERE b.artwork_id = $1 + ORDER BY u.id, b.created_at DESC`, + [art.id] + ); + recipients = r.rows; + } else { + const r = await query( + `SELECT u.id AS user_id, u.email, u.name + FROM auction_bids b + JOIN auction_users u ON u.id = b.user_id + WHERE b.artwork_id = $1 + ORDER BY b.amount DESC, b.created_at ASC + LIMIT 1`, + [art.id] + ); + recipients = r.rows; + } + + for (const user of recipients) { + try { + const result = await notifyEndingSoon({ + userId: user.user_id, + to: user.email, + name: user.name, + art, + }); + if (result.sent) sent += 1; + else skipped += 1; + } catch (err) { + errors += 1; + console.error('[auction/cron] ending_soon error', art.id, err); + } + } + } + + return { + lots_scanned: lots.length, + window_hours: hours, + emails_sent: sent, + skipped, + errors, + }; +} + +/** + * Auto-close active lots past ends_at (sets winner + emails via closeArtwork). + */ +export async function runAutoCloseExpired() { + const { rows: lots } = await query( + `SELECT id FROM auction_artworks + WHERE status = 'active' + AND ends_at <= now() + ORDER BY ends_at ASC + LIMIT 50` + ); + + const admins = await listAdminRecipients(); + if (admins.length === 0) { + console.warn('[auction/cron] auto-close skipped: no admin users seeded'); + return { closed: 0, errors: 0, candidates: lots.length, skipped: 'no_admin' }; + } + + const actingAdmin = admins[0]; + let closed = 0; + let errors = 0; + + for (const lot of lots) { + try { + await closeArtwork(lot.id, { + id: actingAdmin.id, + email: actingAdmin.email, + }); + closed += 1; + } catch (err) { + errors += 1; + console.error('[auction/cron] auto-close error', lot.id, err); + } + } + + return { closed, errors, candidates: lots.length }; +} + +/** + * Full cron tick. + */ +export async function runAuctionCron() { + const ending_soon = await runEndingSoon({ notifyAllBidders: false }); + const auto_close = await runAutoCloseExpired(); + console.info('[auction/cron] complete', { ending_soon, auto_close }); + return { ending_soon, auto_close, ran_at: new Date().toISOString() }; +} diff --git a/lib/auction/db.js b/lib/auction/db.js new file mode 100644 index 0000000..e9b23a4 --- /dev/null +++ b/lib/auction/db.js @@ -0,0 +1,66 @@ +/** + * Postgres client pool for auction API + scripts. + * Uses DATABASE_URL (Neon / Supabase / any Postgres). + */ + +import pg from 'pg'; +import { getDatabaseUrl } from './config.js'; + +const { Pool } = pg; + +/** @type {import('pg').Pool | null} */ +let pool = null; + +export function getPool() { + if (!pool) { + pool = new Pool({ + connectionString: getDatabaseUrl(), + // Neon / cloud Postgres usually need SSL + ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }, + max: 5, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 10_000, + }); + } + return pool; +} + +/** + * Run a callback inside a transaction. + * @template T + * @param {(client: import('pg').PoolClient) => Promise} fn + * @returns {Promise} + */ +export async function withTransaction(fn) { + const client = await getPool().connect(); + try { + await client.query('BEGIN'); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (err) { + try { + await client.query('ROLLBACK'); + } catch { + /* ignore */ + } + throw err; + } finally { + client.release(); + } +} + +/** + * @param {string} text + * @param {unknown[]} [params] + */ +export async function query(text, params = []) { + return getPool().query(text, params); +} + +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/lib/auction/email.js b/lib/auction/email.js new file mode 100644 index 0000000..27f6208 --- /dev/null +++ b/lib/auction/email.js @@ -0,0 +1,376 @@ +/** + * Transactional email via Resend (optional until keys configured). + * Failures are logged; callers should not roll back domain transactions. + */ + +import { getEmailApiKey, getEmailFrom, getSiteUrl } from './config.js'; +import { query } from './db.js'; +import { formatMoney, minimumNextBid } from './money.js'; + +const SITE_NAME = 'Hacker Dojo'; +const PICKUP_BLURB = + process.env.AUCTION_PICKUP_BLURB || + 'Hacker Dojo staff will contact you about payment and pickup. Thank you for supporting the Dojo.'; + +/** + * @param {{ + * userId: string, + * type: string, + * to: string, + * subject: string, + * text: string, + * html?: string, + * artworkId?: string | null, + * bidId?: string | null, + * meta?: Record, + * skipIfDuplicateEndingSoon?: boolean + * }} opts + */ +export async function sendAuctionEmail(opts) { + if (opts.skipIfDuplicateEndingSoon && opts.type === 'auction_ending_soon' && opts.artworkId) { + const existing = await query( + `SELECT id FROM auction_notifications + WHERE type = 'auction_ending_soon' + AND user_id = $1 + AND artwork_id = $2 + LIMIT 1`, + [opts.userId, opts.artworkId] + ); + if (existing.rows[0]) { + return { + sent: false, + notificationId: existing.rows[0].id, + reason: 'already_sent', + }; + } + } + + const apiKey = getEmailApiKey(); + const from = getEmailFrom(); + + let notificationId; + try { + const insert = await query( + `INSERT INTO auction_notifications (user_id, type, artwork_id, bid_id, meta) + VALUES ($1, $2, $3, $4, $5::jsonb) + RETURNING id`, + [ + opts.userId, + opts.type, + opts.artworkId ?? null, + opts.bidId ?? null, + JSON.stringify(opts.meta || {}), + ] + ); + notificationId = insert.rows[0].id; + } catch (err) { + // Partial unique index race for ending_soon + if (opts.type === 'auction_ending_soon' && err && err.code === '23505') { + return { sent: false, notificationId: null, reason: 'already_sent' }; + } + throw err; + } + + if (!apiKey || !from) { + console.warn( + '[auction/email] skipped send (missing RESEND_API_KEY or EMAIL_FROM)', + { type: opts.type, to: opts.to, notificationId } + ); + return { sent: false, notificationId, reason: 'email_not_configured' }; + } + + try { + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from, + to: [opts.to], + subject: opts.subject, + text: opts.text, + html: opts.html || undefined, + }), + }); + + if (!res.ok) { + const body = await res.text(); + console.error('[auction/email] provider error', res.status, body); + return { sent: false, notificationId, reason: 'provider_error' }; + } + + await query( + `UPDATE auction_notifications SET sent_at = now() WHERE id = $1`, + [notificationId] + ); + return { sent: true, notificationId }; + } catch (err) { + console.error('[auction/email] send failed', err); + return { sent: false, notificationId, reason: 'send_failed' }; + } +} + +/** + * Dev-friendly: include OTP in response when email not configured or NODE_ENV=development. + */ +export function shouldExposeDevOtp() { + if (process.env.AUCTION_DEV_OTP === '1') return true; + if (!getEmailApiKey()) return true; + return process.env.NODE_ENV !== 'production'; +} + +export function artworkUrl(artworkId) { + return `${getSiteUrl()}/auction/artwork/?id=${encodeURIComponent(artworkId)}`; +} + +function footerText() { + return [ + '', + '—', + `${SITE_NAME} Silent Auction`, + 'You received this because you bid on or manage a Dojo auction lot.', + getSiteUrl() + '/auction/', + ].join('\n'); +} + +function footerHtml() { + const site = getSiteUrl(); + return `
+

${SITE_NAME} Silent Auction
+You received this because you bid on or manage a Dojo auction lot.
+View auction

`; +} + +/** + * @param {Record} art + */ +function endsAtLabel(art) { + try { + return new Date(/** @type {string} */ (art.ends_at)).toUTCString(); + } catch { + return String(art.ends_at || ''); + } +} + +/** + * @param {Record} art + */ +function minNextLabel(art) { + return formatMoney( + minimumNextBid({ + starting_bid: art.starting_bid, + current_bid: art.current_bid, + minimum_increment: art.minimum_increment, + }) + ); +} + +/** @param {Record} art */ +export async function notifyBidReceived({ userId, to, name, art, bid }) { + const url = artworkUrl(String(art.id)); + const title = String(art.title); + const amount = formatMoney(bid.amount); + const current = formatMoney(art.current_bid); + const minNext = minNextLabel(art); + const subject = `Bid received: ${title}`; + const text = [ + `Hi ${name || 'there'},`, + '', + `We received your bid of $${amount} on "${title}".`, + `Current high bid: $${current}`, + `Minimum next bid: $${minNext}`, + `Ends: ${endsAtLabel(art)}`, + '', + url, + footerText(), + ].join('\n'); + const html = `

Hi ${name || 'there'},

+

We received your bid of $${amount} on ${title}.

+
    +
  • Current high bid: $${current}
  • +
  • Minimum next bid: $${minNext}
  • +
  • Ends: ${endsAtLabel(art)}
  • +
+

View lot

${footerHtml()}`; + + return sendAuctionEmail({ + userId, + type: 'bid_received', + to, + subject, + text, + html, + artworkId: String(art.id), + bidId: String(bid.id), + meta: { amount, current_bid: current }, + }); +} + +/** @param {Record} art */ +export async function notifyOutbid({ userId, to, name, art, yourAmount, bidId }) { + const url = artworkUrl(String(art.id)); + const title = String(art.title); + const current = formatMoney(art.current_bid); + const minNext = minNextLabel(art); + const yours = formatMoney(yourAmount); + const subject = `You've been outbid on ${title}`; + const text = [ + `Hi ${name || 'there'},`, + '', + `Someone placed a higher bid on "${title}".`, + `Your bid was $${yours}.`, + `Current high bid: $${current}`, + `Minimum next bid: $${minNext}`, + `Ends: ${endsAtLabel(art)}`, + '', + url, + footerText(), + ].join('\n'); + const html = `

Hi ${name || 'there'},

+

Someone placed a higher bid on ${title}.

+
    +
  • Your bid: $${yours}
  • +
  • Current high bid: $${current}
  • +
  • Minimum next bid: $${minNext}
  • +
  • Ends: ${endsAtLabel(art)}
  • +
+

Bid again

${footerHtml()}`; + + return sendAuctionEmail({ + userId, + type: 'outbid', + to, + subject, + text, + html, + artworkId: String(art.id), + bidId: bidId ? String(bidId) : null, + meta: { your_amount: yours, current_bid: current }, + }); +} + +/** @param {Record} art */ +export async function notifyWinner({ userId, to, name, art, winningAmount, bidId }) { + const url = artworkUrl(String(art.id)); + const title = String(art.title); + const amount = formatMoney(winningAmount); + const subject = `You won: ${title}`; + const text = [ + `Hi ${name || 'there'},`, + '', + `Congratulations — you won "${title}" with a bid of $${amount}.`, + PICKUP_BLURB, + '', + url, + footerText(), + ].join('\n'); + const html = `

Hi ${name || 'there'},

+

Congratulations — you won ${title} with a bid of $${amount}.

+

${PICKUP_BLURB}

+

View lot

${footerHtml()}`; + + return sendAuctionEmail({ + userId, + type: 'winner', + to, + subject, + text, + html, + artworkId: String(art.id), + bidId: bidId ? String(bidId) : null, + meta: { winning_amount: amount }, + }); +} + +/** @param {Record} art */ +export async function notifyAuctionClosed({ userId, to, art, winnerEmail, winningAmount, bidCount }) { + const url = artworkUrl(String(art.id)); + const title = String(art.title); + const winAmt = winningAmount != null ? formatMoney(winningAmount) : null; + const subject = `Auction closed: ${title}`; + const text = [ + `Lot closed: "${title}"`, + winAmt && winnerEmail + ? `Winner: ${winnerEmail} at $${winAmt}` + : 'Winner: none (no bids)', + `Bid count: ${bidCount ?? '—'}`, + '', + url, + footerText(), + ].join('\n'); + const html = `

Lot closed: ${title}

+

${ + winAmt && winnerEmail + ? `Winner: ${winnerEmail} at $${winAmt}` + : 'Winner: none (no bids)' + }

+

Bid count: ${bidCount ?? '—'}

+

View lot

${footerHtml()}`; + + return sendAuctionEmail({ + userId, + type: 'auction_closed', + to, + subject, + text, + html, + artworkId: String(art.id), + meta: { + winner_email: winnerEmail || null, + winning_amount: winAmt, + bid_count: bidCount ?? null, + }, + }); +} + +/** @param {Record} art */ +export async function notifyEndingSoon({ userId, to, name, art }) { + const url = artworkUrl(String(art.id)); + const title = String(art.title); + const current = formatMoney(art.current_bid ?? art.starting_bid); + const minNext = minNextLabel(art); + const subject = `Ending soon: ${title}`; + const text = [ + `Hi ${name || 'there'},`, + '', + `"${title}" ends soon.`, + `Current high bid: $${current}`, + `Minimum next bid: $${minNext}`, + `Ends: ${endsAtLabel(art)}`, + '', + url, + footerText(), + ].join('\n'); + const html = `

Hi ${name || 'there'},

+

${title} ends soon.

+
    +
  • Current high bid: $${current}
  • +
  • Minimum next bid: $${minNext}
  • +
  • Ends: ${endsAtLabel(art)}
  • +
+

View lot

${footerHtml()}`; + + return sendAuctionEmail({ + userId, + type: 'auction_ending_soon', + to, + subject, + text, + html, + artworkId: String(art.id), + meta: { current_bid: current }, + skipIfDuplicateEndingSoon: true, + }); +} + +/** + * List all admin users for closed notices. + */ +export async function listAdminRecipients() { + const { rows } = await query( + `SELECT id, email, name FROM auction_users WHERE role = 'admin' ORDER BY created_at ASC` + ); + return rows; +} diff --git a/lib/auction/errors.js b/lib/auction/errors.js new file mode 100644 index 0000000..15f25eb --- /dev/null +++ b/lib/auction/errors.js @@ -0,0 +1,59 @@ +/** + * Auction API error codes — see docs/auction/API.md + */ + +export const ErrorCodes = { + VALIDATION_ERROR: 'VALIDATION_ERROR', + INVALID_AMOUNT: 'INVALID_AMOUNT', + BID_TOO_LOW: 'BID_TOO_LOW', + AUCTION_CLOSED: 'AUCTION_CLOSED', + AUCTION_NOT_ACTIVE: 'AUCTION_NOT_ACTIVE', + UNAUTHENTICATED: 'UNAUTHENTICATED', + FORBIDDEN: 'FORBIDDEN', + NOT_FOUND: 'NOT_FOUND', + ARTWORK_NOT_DELETABLE: 'ARTWORK_NOT_DELETABLE', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + CONFIG_ERROR: 'CONFIG_ERROR', +}; + +/** @type {Record} */ +const HTTP_BY_CODE = { + VALIDATION_ERROR: 400, + INVALID_AMOUNT: 400, + BID_TOO_LOW: 409, + AUCTION_CLOSED: 409, + AUCTION_NOT_ACTIVE: 409, + UNAUTHENTICATED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + ARTWORK_NOT_DELETABLE: 409, + RATE_LIMITED: 429, + SERVER_ERROR: 500, + CONFIG_ERROR: 500, +}; + +export class ApiError extends Error { + /** + * @param {string} code + * @param {string} message + * @param {number} [status] + * @param {Record} [details] + */ + constructor(code, message, status, details) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.status = status ?? HTTP_BY_CODE[code] ?? 500; + this.details = details ?? undefined; + } +} + +/** + * @param {string} code + * @param {string} message + * @param {Record} [details] + */ +export function apiError(code, message, details) { + return new ApiError(code, message, undefined, details); +} diff --git a/lib/auction/http.js b/lib/auction/http.js new file mode 100644 index 0000000..c0f84e5 --- /dev/null +++ b/lib/auction/http.js @@ -0,0 +1,168 @@ +/** + * HTTP helpers for Vercel-style auction handlers. + */ + +import { ApiError, ErrorCodes } from './errors.js'; +import { getCorsOrigins } from './config.js'; + +/** + * @param {import('http').IncomingMessage} req + * @param {import('http').ServerResponse} res + * @param {{ methods?: string[] }} [opts] + */ +export function setCors(req, res, opts = {}) { + const methods = (opts.methods || ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']).join(', '); + const origins = getCorsOrigins(); + const origin = req.headers.origin; + + if (origin && origins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } else if (origins.includes('*')) { + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (origins.length === 1) { + // Allow single configured origin even without Origin header echo + res.setHeader('Access-Control-Allow-Origin', origins[0]); + } + + res.setHeader('Access-Control-Allow-Methods', methods); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-Requested-With' + ); + res.setHeader('Access-Control-Allow-Credentials', 'true'); +} + +/** + * @param {import('http').ServerResponse} res + * @param {number} status + * @param {unknown} body + */ +export function json(res, status, body) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(body)); +} + +/** + * @param {import('http').ServerResponse} res + * @param {unknown} err + */ +export function sendError(res, err) { + if (err instanceof ApiError) { + return json(res, err.status, { + error: { + code: err.code, + message: err.message, + ...(err.details ? { details: err.details } : {}), + }, + }); + } + + console.error('[auction] unhandled error', err); + return json(res, 500, { + error: { + code: ErrorCodes.SERVER_ERROR, + message: 'Unexpected server error', + }, + }); +} + +/** + * Parse JSON body from Vercel/Node request. + * @param {import('http').IncomingMessage & { body?: unknown }} req + * @returns {Promise>} + */ +export async function readJsonBody(req) { + if (req.body != null && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) { + return /** @type {Record} */ (req.body); + } + + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + throw new ApiError(ErrorCodes.VALIDATION_ERROR, 'Invalid JSON body'); + } +} + +/** + * @param {import('http').IncomingMessage} req + * @returns {Record} + */ +export function parseCookies(req) { + const header = req.headers.cookie || ''; + /** @type {Record} */ + const out = {}; + for (const part of header.split(';')) { + const idx = part.indexOf('='); + if (idx === -1) continue; + const key = part.slice(0, idx).trim(); + const val = part.slice(idx + 1).trim(); + if (key) out[key] = decodeURIComponent(val); + } + return out; +} + +/** + * @param {import('http').ServerResponse} res + * @param {string} name + * @param {string} value + * @param {{ maxAge?: number, httpOnly?: boolean, secure?: boolean, sameSite?: string, path?: string }} [opts] + */ +export function setCookie(res, name, value, opts = {}) { + const parts = [ + `${name}=${encodeURIComponent(value)}`, + `Path=${opts.path || '/'}`, + `SameSite=${opts.sameSite || 'Lax'}`, + ]; + if (opts.maxAge != null) parts.push(`Max-Age=${opts.maxAge}`); + if (opts.httpOnly !== false) parts.push('HttpOnly'); + if (opts.secure !== false && process.env.NODE_ENV === 'production') { + parts.push('Secure'); + } else if (opts.secure) { + parts.push('Secure'); + } + const prev = res.getHeader('Set-Cookie'); + if (!prev) { + res.setHeader('Set-Cookie', parts.join('; ')); + } else if (Array.isArray(prev)) { + res.setHeader('Set-Cookie', [...prev, parts.join('; ')]); + } else { + res.setHeader('Set-Cookie', [String(prev), parts.join('; ')]); + } +} + +/** + * @param {import('http').ServerResponse} res + * @param {string} name + */ +export function clearCookie(res, name) { + setCookie(res, name, '', { maxAge: 0 }); +} + +/** + * Wrap a handler with CORS + error mapping. + * @param {(req: any, res: any) => Promise} fn + * @param {{ methods?: string[] }} [opts] + */ +export function withHandler(fn, opts = {}) { + return async function handler(req, res) { + setCors(req, res, opts); + if (req.method === 'OPTIONS') { + res.statusCode = 204; + return res.end(); + } + try { + await fn(req, res); + } catch (err) { + sendError(res, err); + } + }; +} diff --git a/lib/auction/index.js b/lib/auction/index.js new file mode 100644 index 0000000..c9711cd --- /dev/null +++ b/lib/auction/index.js @@ -0,0 +1,17 @@ +/** + * Auction Space shared library. + */ + +export * from './config.js'; +export * from './db.js'; +export * from './errors.js'; +export * from './money.js'; +export * from './http.js'; +export * from './auth.js'; +export * from './users.js'; +export * from './artworks.js'; +export * from './email.js'; +export * from './login.js'; +export * from './bids.js'; +export * from './admin-artworks.js'; +export * from './cron.js'; diff --git a/lib/auction/login.js b/lib/auction/login.js new file mode 100644 index 0000000..053b2af --- /dev/null +++ b/lib/auction/login.js @@ -0,0 +1,133 @@ +/** + * Magic-link / OTP issuance and verification. + */ + +import crypto from 'node:crypto'; +import { query } from './db.js'; +import { hashToken } from './auth.js'; +import { findOrCreateBidder } from './users.js'; +import { apiError, ErrorCodes } from './errors.js'; +import { sendAuctionEmail, shouldExposeDevOtp } from './email.js'; +import { getSiteUrl } from './config.js'; + +const OTP_TTL_MS = 15 * 60 * 1000; +const MAX_REQUESTS_PER_EMAIL = 5; + +/** + * @param {string} email + */ +function normalizeEmail(email) { + return String(email || '').trim().toLowerCase(); +} + +/** + * @param {string} email + */ +export async function assertLoginRateLimit(email) { + const normalized = normalizeEmail(email); + const { rows } = await query( + `SELECT count(*)::int AS c + FROM auction_login_tokens + WHERE lower(email) = $1 + AND created_at > now() - interval '1 hour'`, + [normalized] + ); + if ((rows[0]?.c || 0) >= MAX_REQUESTS_PER_EMAIL) { + throw apiError(ErrorCodes.RATE_LIMITED, 'Too many login requests. Try again later.'); + } +} + +/** + * Issue a 6-digit OTP (stored hashed). Returns raw token for email/dev. + * @param {{ email: string, name?: string | null }} input + */ +export async function issueLoginToken(input) { + const email = normalizeEmail(input.email); + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Valid email is required'); + } + + await assertLoginRateLimit(email); + + const raw = String(crypto.randomInt(100000, 999999)); + const tokenHash = hashToken(raw); + const expiresAt = new Date(Date.now() + OTP_TTL_MS); + const name = input.name ? String(input.name).trim().slice(0, 120) : null; + + await query( + `INSERT INTO auction_login_tokens (email, token_hash, name, expires_at) + VALUES ($1, $2, $3, $4)`, + [email, tokenHash, name, expiresAt.toISOString()] + ); + + // Best-effort email; do not create user until verify (avoids junk accounts). + // Still try to email if user exists for personalization — skip Notification user_id requirement by using a stub path. + // Notification table requires user_id — create/find user early so we can audit. + const user = await findOrCreateBidder(email, name); + const site = getSiteUrl(); + await sendAuctionEmail({ + userId: user.id, + type: 'login_otp', + to: email, + subject: 'Your Hacker Dojo auction login code', + text: [ + `Your one-time login code is: ${raw}`, + '', + `It expires in 15 minutes.`, + `If you did not request this, you can ignore this email.`, + '', + `Auction: ${site}/auction/`, + ].join('\n'), + html: `

Your one-time login code is: ${raw}

+

It expires in 15 minutes.

+

Hacker Dojo Silent Auction

`, + meta: { purpose: 'login' }, + }); + + return { + email, + userId: user.id, + expiresAt, + // Only returned when email is not configured / dev mode + dev_otp: shouldExposeDevOtp() ? raw : undefined, + }; +} + +/** + * Verify OTP and return user (marks token used). + * @param {{ email: string, token: string }} input + */ +export async function verifyLoginToken(input) { + const email = normalizeEmail(input.email); + const raw = String(input.token || '').trim(); + if (!email || !raw) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Email and code are required'); + } + + const tokenHash = hashToken(raw); + const { rows } = await query( + `SELECT id, email, name, expires_at, used_at + FROM auction_login_tokens + WHERE token_hash = $1 + AND lower(email) = $2 + ORDER BY created_at DESC + LIMIT 1`, + [tokenHash, email] + ); + + const row = rows[0]; + if (!row || row.used_at) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Invalid or expired code'); + } + if (new Date(row.expires_at).getTime() < Date.now()) { + throw apiError(ErrorCodes.VALIDATION_ERROR, 'Invalid or expired code'); + } + + await query( + `UPDATE auction_login_tokens SET used_at = now() WHERE id = $1`, + [row.id] + ); + + const user = await findOrCreateBidder(email, row.name); + return user; +} diff --git a/lib/auction/money.js b/lib/auction/money.js new file mode 100644 index 0000000..a7ef2c3 --- /dev/null +++ b/lib/auction/money.js @@ -0,0 +1,84 @@ +/** + * Money helpers for auction bids (USD cents-safe via string/number → fixed 2dp). + */ + +import { apiError, ErrorCodes } from './errors.js'; + +/** + * Parse a money input into a Number with 2 decimal places. + * Accepts number or string ("35", "35.00", "35.5"). + * @param {unknown} value + * @returns {number} + */ +export function parseMoney(value) { + if (value == null || value === '') { + throw apiError(ErrorCodes.INVALID_AMOUNT, 'Amount is required'); + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || value <= 0) { + throw apiError(ErrorCodes.INVALID_AMOUNT, 'Amount must be a positive number'); + } + return roundMoney(value); + } + const raw = String(value).trim().replace(/[$,]/g, ''); + if (!/^\d+(\.\d{1,2})?$/.test(raw)) { + throw apiError(ErrorCodes.INVALID_AMOUNT, 'Amount must be a positive money value (max 2 decimals)'); + } + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + throw apiError(ErrorCodes.INVALID_AMOUNT, 'Amount must be a positive number'); + } + return roundMoney(n); +} + +/** + * @param {number} n + * @returns {number} + */ +export function roundMoney(n) { + return Math.round(n * 100) / 100; +} + +/** + * Format for API JSON (always two decimals as string). + * @param {number | string | null | undefined} n + * @returns {string | null} + */ +export function formatMoney(n) { + if (n == null || n === '') return null; + const num = typeof n === 'number' ? n : Number(n); + if (!Number.isFinite(num)) return null; + return roundMoney(num).toFixed(2); +} + +/** + * Minimum next bid: starting_bid if no current_bid, else current + increment. + * @param {{ starting_bid: number|string, current_bid: number|string|null, minimum_increment: number|string }} artwork + * @returns {number} + */ +export function minimumNextBid(artwork) { + const starting = Number(artwork.starting_bid); + const current = + artwork.current_bid == null || artwork.current_bid === '' + ? null + : Number(artwork.current_bid); + const inc = Number(artwork.minimum_increment); + if (current == null || !Number.isFinite(current)) { + return roundMoney(starting); + } + return roundMoney(current + inc); +} + +/** + * @param {number} amount + * @param {number} minimum + */ +export function assertBidMeetsMinimum(amount, minimum) { + if (roundMoney(amount) + 1e-9 < roundMoney(minimum)) { + throw apiError( + ErrorCodes.BID_TOO_LOW, + `Bid must be at least ${formatMoney(minimum)}`, + { minimum_next_bid: formatMoney(minimum) } + ); + } +} diff --git a/lib/auction/users.js b/lib/auction/users.js new file mode 100644 index 0000000..47930f0 --- /dev/null +++ b/lib/auction/users.js @@ -0,0 +1,55 @@ +/** + * User persistence helpers. + */ + +import { query } from './db.js'; + +/** + * Upsert admin by email (seed / bootstrap). + * @param {string} email + * @param {string} [name] + */ +export async function ensureAdminUser(email, name = 'Auction Admin') { + const normalized = email.trim().toLowerCase(); + const { rows } = await query( + `INSERT INTO auction_users (email, name, role) + VALUES ($1, $2, 'admin') + ON CONFLICT (email) DO UPDATE + SET role = 'admin', + name = COALESCE(EXCLUDED.name, auction_users.name) + RETURNING id, email, name, role, created_at`, + [normalized, name] + ); + return rows[0]; +} + +/** + * Find or create a bidder. + * @param {string} email + * @param {string | null} [name] + */ +export async function findOrCreateBidder(email, name = null) { + const normalized = email.trim().toLowerCase(); + const existing = await query( + `SELECT id, email, name, role FROM auction_users WHERE lower(email) = $1 LIMIT 1`, + [normalized] + ); + if (existing.rows[0]) { + if (name && !existing.rows[0].name) { + const updated = await query( + `UPDATE auction_users SET name = $2 WHERE id = $1 + RETURNING id, email, name, role`, + [existing.rows[0].id, name] + ); + return updated.rows[0]; + } + return existing.rows[0]; + } + const inserted = await query( + `INSERT INTO auction_users (email, name, role) + VALUES ($1, $2, 'bidder') + RETURNING id, email, name, role`, + [normalized, name] + ); + return inserted.rows[0]; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7db7d09 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,164 @@ +{ + "name": "hackerdojo-org", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hackerdojo-org", + "version": "0.1.0", + "dependencies": { + "pg": "^8.16.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0eed9fc --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "hackerdojo-org", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Hacker Dojo website — static Jekyll site + Vercel serverless auction API", + "engines": { + "node": ">=20" + }, + "scripts": { + "auction:migrate": "node scripts/auction-migrate.js", + "auction:seed-admin": "node scripts/auction-seed-admin.js", + "auction:seed-demo": "node scripts/auction-seed-demo-lot.js", + "auction:bootstrap": "node scripts/auction-migrate.js && node scripts/auction-seed-admin.js && node scripts/auction-seed-demo-lot.js", + "auction:dev": "node scripts/auction-dev-server.js" + }, + "dependencies": { + "pg": "^8.16.3" + } +} diff --git a/scripts/auction-dev-server.js b/scripts/auction-dev-server.js new file mode 100644 index 0000000..52e0422 --- /dev/null +++ b/scripts/auction-dev-server.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/** + * Local Auction API server for demos (no Vercel CLI required). + * Loads .env.local / .env then routes /api/auction/* to handlers. + * + * node scripts/auction-dev-server.js + * → http://127.0.0.1:3000 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const PORT = Number(process.env.PORT || 3000); + +function loadEnvFile() { + for (const name of ['.env.local', '.env']) { + const p = path.join(root, name); + if (!fs.existsSync(p)) continue; + const text = fs.readFileSync(p, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (process.env[key] == null) process.env[key] = val; + } + } +} + +loadEnvFile(); + +/** @type {Array<{ match: (url: URL, method: string) => Record|null, load: () => Promise }>} */ +const routes = [ + { + match: (url, method) => + url.pathname === '/api/auction/health' && (method === 'GET' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/health.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/artworks' && + (method === 'GET' || method === 'POST' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/artworks.js')).href), + }, + { + match: (url, method) => { + const m = url.pathname.match(/^\/api\/auction\/artworks\/([^/]+)\/close$/); + if (m && (method === 'POST' || method === 'OPTIONS')) return { id: decodeURIComponent(m[1]) }; + return null; + }, + load: () => + import(pathToFileURL(path.join(root, 'api/auction/artworks/[id]/close.js')).href), + }, + { + match: (url, method) => { + const m = url.pathname.match(/^\/api\/auction\/artworks\/([^/]+)$/); + if ( + m && + (method === 'GET' || method === 'PATCH' || method === 'DELETE' || method === 'OPTIONS') + ) { + return { id: decodeURIComponent(m[1]) }; + } + return null; + }, + load: () => import(pathToFileURL(path.join(root, 'api/auction/artworks/[id].js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/bids' && (method === 'POST' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/bids.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/me' && (method === 'GET' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/me.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/auth/request-link' && + (method === 'POST' || method === 'OPTIONS') + ? {} + : null, + load: () => + import(pathToFileURL(path.join(root, 'api/auction/auth/request-link.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/auth/verify' && (method === 'POST' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/auth/verify.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/auth/session' && + (method === 'GET' || method === 'DELETE' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/auth/session.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/admin/artworks' && + (method === 'GET' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/admin/artworks.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/admin/ping' && (method === 'GET' || method === 'OPTIONS') + ? {} + : null, + load: () => import(pathToFileURL(path.join(root, 'api/auction/admin/ping.js')).href), + }, + { + match: (url, method) => + url.pathname === '/api/auction/cron/ending-soon' && + (method === 'GET' || method === 'POST' || method === 'OPTIONS') + ? {} + : null, + load: () => + import(pathToFileURL(path.join(root, 'api/auction/cron/ending-soon.js')).href), + }, +]; + +function wrapRes(res) { + /** @type {Record} */ + const headers = {}; + return { + statusCode: 200, + setHeader(k, v) { + headers[k.toLowerCase()] = v; + res.setHeader(k, v); + }, + getHeader(k) { + return headers[k.toLowerCase()] ?? res.getHeader(k); + }, + end(body) { + if (!res.headersSent) { + res.statusCode = this.statusCode; + } + res.end(body); + }, + }; +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + const method = req.method || 'GET'; + + for (const route of routes) { + const params = route.match(url, method); + if (params == null) continue; + const mod = await route.load(); + const handler = mod.default; + const fakeReq = Object.assign(req, { + query: { ...Object.fromEntries(url.searchParams.entries()), ...params }, + url: url.pathname + url.search, + }); + const fakeRes = wrapRes(res); + await handler(fakeReq, fakeRes); + return; + } + + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'No route' } })); + } catch (err) { + console.error(err); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + error: { code: 'SERVER_ERROR', message: err?.message || 'error' }, + }) + ); + } + } +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`Auction API listening on http://127.0.0.1:${PORT}`); +}); diff --git a/scripts/auction-migrate.js b/scripts/auction-migrate.js new file mode 100644 index 0000000..2cc4728 --- /dev/null +++ b/scripts/auction-migrate.js @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * Apply SQL files in db/migrations/ in lexical order. + * Tracks applied ids in auction_schema_migrations. + * + * Usage: + * DATABASE_URL=... node scripts/auction-migrate.js + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; + +const { Client } = pg; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const migrationsDir = path.join(root, 'db', 'migrations'); + +function loadEnvFile() { + for (const name of ['.env.local', '.env']) { + const p = path.join(root, name); + if (!fs.existsSync(p)) continue; + const text = fs.readFileSync(p, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (process.env[key] == null) process.env[key] = val; + } + } +} + +async function main() { + loadEnvFile(); + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error('DATABASE_URL is required'); + process.exit(1); + } + + const client = new Client({ + connectionString: databaseUrl, + ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }, + }); + + await client.connect(); + console.log('Connected.'); + + // Ensure bookkeeping table exists even before first migration body. + await client.query(` + CREATE TABLE IF NOT EXISTS auction_schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + const files = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith('.sql')) + .sort(); + + if (files.length === 0) { + console.log('No migrations found.'); + await client.end(); + return; + } + + for (const file of files) { + const id = file.replace(/\.sql$/, ''); + const already = await client.query( + `SELECT 1 FROM auction_schema_migrations WHERE id = $1`, + [id] + ); + if (already.rowCount > 0) { + console.log(`skip ${file} (already applied)`); + continue; + } + + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'); + console.log(`apply ${file} …`); + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query( + `INSERT INTO auction_schema_migrations (id) VALUES ($1) + ON CONFLICT (id) DO NOTHING`, + [id] + ); + await client.query('COMMIT'); + console.log(`ok ${file}`); + } catch (err) { + await client.query('ROLLBACK'); + console.error(`fail ${file}`); + console.error(err); + await client.end(); + process.exit(1); + } + } + + await client.end(); + console.log('Migrations complete.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/auction-seed-admin.js b/scripts/auction-seed-admin.js new file mode 100644 index 0000000..28f4615 --- /dev/null +++ b/scripts/auction-seed-admin.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Seed / promote ADMIN_EMAIL to role=admin. + * + * Usage: + * DATABASE_URL=... ADMIN_EMAIL=admin@hackerdojo.org node scripts/auction-seed-admin.js + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ensureAdminUser } from '../lib/auction/users.js'; +import { closePool } from '../lib/auction/db.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +function loadEnvFile() { + for (const name of ['.env.local', '.env']) { + const p = path.join(root, name); + if (!fs.existsSync(p)) continue; + const text = fs.readFileSync(p, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (process.env[key] == null) process.env[key] = val; + } + } +} + +async function main() { + loadEnvFile(); + const email = process.env.ADMIN_EMAIL; + if (!email) { + console.error('ADMIN_EMAIL is required'); + process.exit(1); + } + if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL is required'); + process.exit(1); + } + + const name = process.env.ADMIN_NAME || 'Auction Admin'; + const user = await ensureAdminUser(email, name); + console.log('Admin ready:', { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }); + await closePool(); +} + +main().catch(async (err) => { + console.error(err); + try { + await closePool(); + } catch { + /* ignore */ + } + process.exit(1); +}); diff --git a/scripts/auction-seed-demo-lot.js b/scripts/auction-seed-demo-lot.js new file mode 100644 index 0000000..15bd072 --- /dev/null +++ b/scripts/auction-seed-demo-lot.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Seed one active demo artwork for local/staging gallery demos (Slice 1). + * Idempotent: skips if a lot with the demo title already exists. + * + * Usage: + * DATABASE_URL=... node scripts/auction-seed-demo-lot.js + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { query, closePool } from '../lib/auction/db.js'; +import { ensureAdminUser } from '../lib/auction/users.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +const DEMO_TITLE = 'Torii at Dusk (Demo Lot)'; + +function loadEnvFile() { + for (const name of ['.env.local', '.env']) { + const p = path.join(root, name); + if (!fs.existsSync(p)) continue; + const text = fs.readFileSync(p, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (process.env[key] == null) process.env[key] = val; + } + } +} + +async function main() { + loadEnvFile(); + if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL is required'); + process.exit(1); + } + + const adminEmail = process.env.ADMIN_EMAIL || 'admin@hackerdojo.org'; + const admin = await ensureAdminUser(adminEmail, process.env.ADMIN_NAME || 'Auction Admin'); + + const existing = await query( + `SELECT id, title, status, ends_at FROM auction_artworks WHERE title = $1 LIMIT 1`, + [DEMO_TITLE] + ); + if (existing.rows[0]) { + console.log('Demo lot already present:', existing.rows[0]); + await closePool(); + return; + } + + const ends = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // +7 days + const images = [ + 'https://images.unsplash.com/photo-1528164344705-47542687000d?w=800&q=80', + ]; + + const { rows } = await query( + `INSERT INTO auction_artworks ( + title, artist, description, images, + starting_bid, current_bid, minimum_increment, + ends_at, status, created_by, updated_at + ) VALUES ( + $1, $2, $3, $4::jsonb, + $5, NULL, $6, + $7, 'active', $8, now() + ) + RETURNING id, title, status, starting_bid, ends_at`, + [ + DEMO_TITLE, + 'A. Maker', + 'Demo silent-auction lot for gallery and countdown smoke tests. Replace with real donated artwork in admin (Slice 3).', + JSON.stringify(images), + '20.00', + '5.00', + ends.toISOString(), + admin.id, + ] + ); + + console.log('Demo lot created:', rows[0]); + await closePool(); +} + +main().catch(async (err) => { + console.error(err); + try { + await closePool(); + } catch { + /* ignore */ + } + process.exit(1); +}); diff --git a/static/css/auction.css b/static/css/auction.css new file mode 100644 index 0000000..214a233 --- /dev/null +++ b/static/css/auction.css @@ -0,0 +1,904 @@ +/* Auction Space — aligned with static/css/style.css (Hacker Dojo site system) */ + +/* Site tokens (mirrored for clarity) */ +:root { + --hd-bg: #f5f5f5; + --hd-red: #e13838; + --hd-red-deep: #df3f33; + --hd-ink: #111; + --hd-muted: #505050; + --hd-body: #444; + --hd-card-shadow: 0px 0px 1px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.04), + 0px 0px 3px rgba(0, 0, 0, 0.02), 0px 0px 5px rgba(0, 0, 0, 0.03); +} + +/* Section shell — same rhythm as .section on the marketing site */ +.auction-section { + background: var(--hd-bg); + padding: 60px 40px 80px; + text-align: center; + margin: auto; +} + +.auction-section .section-header { + margin: auto; + text-align: center; + margin-bottom: 36px; + max-width: 700px; +} + +.auction-section .section-title { + font-size: 20px; + font-weight: 600; + font-family: "Rajdhani", sans-serif; + letter-spacing: 0.5px; + color: var(--hd-red-deep); + text-transform: uppercase; + text-align: center; + margin-bottom: 16px; +} + +.auction-section .section-description { + font-size: 18px; + font-family: "Saira", sans-serif; + color: var(--hd-body); + margin-bottom: 0; + line-height: 1.45; +} + +.auction-section-content { + max-width: 960px; + margin: 0 auto; + text-align: left; +} + +/* Soft hero wash (matches pricing / hero gradient language) */ +.auction-hero { + background-image: linear-gradient(0deg, #6d3bdb2e 8%, #f5f5f5 48%, #f5f5f5 100%); + padding: 56px 40px 28px; + text-align: center; +} + +.auction-hero-tagline { + font-family: "Rajdhani", sans-serif; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + background: #fff; + color: #516368; + display: inline-block; + padding: 3px 10px 2px; + border-radius: 6px; + margin-bottom: 14px; + box-shadow: var(--hd-card-shadow); +} + +.auction-hero-title { + font-family: "Rajdhani", sans-serif; + font-size: 48px; + font-weight: 600; + color: var(--hd-ink); + line-height: 1.15; + margin-bottom: 12px; +} + +.auction-hero-desc { + font-size: 18px; + color: var(--hd-body); + max-width: 560px; + margin: 0 auto; + font-family: "Saira", sans-serif; + line-height: 1.45; +} + +@media (max-width: 700px) { + .auction-section, + .auction-hero { + padding-left: 20px; + padding-right: 20px; + } + + .auction-hero-title { + font-size: 36px; + } +} + +/* Buttons — extend site .button without fighting it */ +.auction-section .button, +.auction-modal .button, +.admin-page .button { + border: none; + cursor: pointer; + display: inline-block; + font-family: "Rajdhani", sans-serif; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + padding: 12px 20px; + border-radius: 12px; + background: #fff; + color: #000; + text-decoration: none; + box-sizing: border-box; + line-height: 1.2; +} + +.auction-section .button-red, +.auction-modal .button-red, +.admin-page .button-red { + color: #fff; + background: var(--hd-red); +} + +.auction-section .button-green, +.admin-page .button-green { + color: #fff; + background: #018669; +} + +.auction-section .button-darkblue, +.admin-page .button-darkblue { + color: #fff; + background: #444c5a; +} + +.auction-section .button:disabled, +.auction-section .button[disabled], +.auction-modal .button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Gallery grid — plan-card / intro card language */ +.auction-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 20px; +} + +.auction-card { + background: #fff; + border-radius: 15px; + overflow: hidden; + box-shadow: var(--hd-card-shadow); + border: 3px solid #fff; + display: flex; + flex-direction: column; + transition: 0.2s; + text-align: left; +} + +.auction-card:hover { + border-color: peachpuff; +} + +.auction-card a.auction-card-link { + color: inherit; + text-decoration: none; + display: flex; + flex-direction: column; + flex: 1; +} + +.auction-card-image { + aspect-ratio: 4 / 3; + background: #eee; + object-fit: cover; + width: 100%; + display: block; +} + +.auction-card-image-placeholder { + aspect-ratio: 4 / 3; + background: #f0ebe6; + display: flex; + align-items: center; + justify-content: center; + color: #888; + font-family: "Rajdhani", sans-serif; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; +} + +.auction-card-body { + padding: 18px 20px 22px; + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.auction-card-title { + font-family: "Rajdhani", sans-serif; + font-size: 22px; + font-weight: 600; + color: var(--hd-ink); + line-height: 1.2; +} + +.auction-card-artist { + color: var(--hd-muted); + font-size: 15px; + font-family: "Saira", sans-serif; +} + +.auction-card-meta { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + font-family: "Saira", sans-serif; + color: #333; +} + +.auction-card-meta strong { + font-family: "Rajdhani", sans-serif; + font-size: 18px; + font-weight: 600; + color: var(--hd-ink); +} + +.auction-card-cta { + margin-top: 14px; + font-family: "Rajdhani", sans-serif; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--hd-red); +} + +.auction-countdown { + font-variant-numeric: tabular-nums; + color: var(--hd-red-deep); + font-weight: 600; + font-family: "Rajdhani", sans-serif; + font-size: 15px; + letter-spacing: 0.3px; +} + +.auction-countdown.is-ended { + color: #888; +} + +/* Empty / loading / error — site-native notes */ +.auction-empty, +.auction-error, +.auction-loading { + padding: 28px 20px; + text-align: center; + color: var(--hd-muted); + background: #fff; + border-radius: 15px; + border: 3px solid #fff; + box-shadow: var(--hd-card-shadow); + font-family: "Saira", sans-serif; + font-size: 16px; +} + +.auction-error { + color: var(--hd-red-deep); + border-color: #f5d0cd; + background: #fff8f7; +} + +/* Detail page */ +.auction-detail-wrap { + max-width: 960px; + margin: 0 auto; + text-align: left; +} + +.auction-back { + display: inline-block; + margin-bottom: 18px; + font-family: "Rajdhani", sans-serif; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: #516368; + text-decoration: none; +} + +.auction-back:hover { + color: var(--hd-red); +} + +.auction-detail { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +@media (max-width: 800px) { + .auction-detail { + grid-template-columns: 1fr; + } +} + +.auction-detail-image-wrap { + background: #fff; + border-radius: 15px; + overflow: hidden; + border: 3px solid #fff; + box-shadow: var(--hd-card-shadow); +} + +.auction-detail-image { + width: 100%; + display: block; + aspect-ratio: 4 / 3; + object-fit: cover; + background: #eee; +} + +.auction-detail-info { + background: #fff; + border-radius: 15px; + border: 3px solid #fff; + box-shadow: var(--hd-card-shadow); + padding: 24px 26px 28px; +} + +.auction-detail-info h1 { + font-family: "Rajdhani", sans-serif; + font-size: 32px; + font-weight: 600; + color: var(--hd-ink); + line-height: 1.15; + margin: 0 0 4px; + text-align: left; +} + +.auction-detail-artist { + color: var(--hd-muted); + font-size: 16px; + margin-bottom: 16px; + font-family: "Saira", sans-serif; +} + +.auction-status-pill { + display: inline-block; + font-family: "Rajdhani", sans-serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + padding: 3px 8px 2px; + border-radius: 5px; + background: var(--hd-red); + color: #fff; + margin-bottom: 12px; +} + +.auction-status-pill.is-closed, +.auction-status-pill.is-preview, +.auction-status-pill.is-draft { + background: #444c5a; +} + +.auction-price-block { + background: #f8f4f1; + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 16px; +} + +.auction-price-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + font-size: 15px; + font-family: "Saira", sans-serif; +} + +.auction-price-row .label { + color: var(--hd-muted); +} + +.auction-price-row .value { + font-family: "Rajdhani", sans-serif; + font-weight: 600; + font-size: 17px; + font-variant-numeric: tabular-nums; + color: var(--hd-ink); +} + +.auction-price-row .value.accent { + color: var(--hd-red-deep); + font-size: 22px; +} + +.auction-description { + margin: 0 0 18px; + line-height: 1.55; + color: #2d3340; + white-space: pre-wrap; + font-size: 15px; + font-family: "Saira", sans-serif; +} + +.auction-session { + font-size: 14px; + color: var(--hd-muted); + margin: 0 0 12px; + font-family: "Saira", sans-serif; +} + +.auction-link-btn { + background: none; + border: none; + color: var(--hd-red); + cursor: pointer; + font-family: "Rajdhani", sans-serif; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + text-decoration: none; + padding: 0; +} + +.auction-link-btn:hover { + text-decoration: underline; +} + +.auction-bid-cta { + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; + margin-top: 4px; +} + +.auction-bid-cta.is-ready { + opacity: 1; + cursor: pointer; + pointer-events: auto; +} + +.auction-note { + margin-top: 12px; + font-size: 13px; + color: #888; + font-family: "Saira", sans-serif; +} + +.auction-bids { + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid #eee; +} + +.auction-bids h2 { + font-family: "Rajdhani", sans-serif; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--hd-red-deep); + margin: 0 0 12px; +} + +.auction-bids-list { + list-style: none; + margin: 0; + padding: 0; + border-radius: 12px; + overflow: hidden; + background: #f8f4f1; +} + +.auction-bids-list li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #efe8e1; + font-size: 14px; + font-family: "Saira", sans-serif; +} + +.auction-bids-list li:last-child { + border-bottom: none; +} + +.auction-bids-empty { + color: #888; + font-size: 14px; + padding: 4px 0; + font-family: "Saira", sans-serif; +} + +/* Modal — white card, Dojo red accents */ +body.auction-modal-open { + overflow: hidden; +} + +.auction-modal { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.auction-modal[hidden] { + display: none !important; +} + +.auction-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(17, 17, 17, 0.45); +} + +.auction-modal-dialog { + position: relative; + background: #fff; + border-radius: 15px; + max-width: 420px; + width: 100%; + padding: 28px 26px 24px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18); + z-index: 1; + border: 3px solid #fff; + text-align: left; +} + +.auction-modal-close { + position: absolute; + top: 10px; + right: 14px; + border: none; + background: transparent; + font-size: 1.6rem; + line-height: 1; + cursor: pointer; + color: #888; + font-family: "Saira", sans-serif; +} + +.auction-modal-title { + font-family: "Rajdhani", sans-serif; + font-size: 22px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--hd-red-deep); + margin: 0 0 12px; + padding-right: 24px; +} + +.auction-modal-help { + color: var(--hd-body); + font-size: 15px; + margin: 0 0 14px; + line-height: 1.45; + font-family: "Saira", sans-serif; +} + +.auction-modal-dev { + background: #fff0df; + border: 1px solid peachpuff; + color: #5a4030; + padding: 10px 12px; + border-radius: 10px; + font-size: 14px; + margin: 0 0 12px; + font-family: "Saira", sans-serif; +} + +.auction-form label { + display: block; + font-family: "Rajdhani", sans-serif; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: #505050; + margin-bottom: 12px; + text-align: left; +} + +.auction-form input, +.auction-form textarea, +.auction-form select { + display: block; + width: 100%; + margin-top: 6px; + padding: 11px 12px; + border: 1px solid #ddd; + border-radius: 10px; + font-family: "Saira", sans-serif; + font-size: 15px; + box-sizing: border-box; + background: #fafafa; + color: #111; +} + +.auction-form input:focus, +.auction-form textarea:focus, +.auction-form select:focus { + outline: none; + border-color: #c4b5e8; + box-shadow: 0 0 0 3px rgba(109, 59, 219, 0.12); +} + +.auction-form .optional { + font-weight: 500; + color: #999; + text-transform: none; + letter-spacing: 0; + font-family: "Saira", sans-serif; + font-size: 12px; +} + +.auction-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; + flex-wrap: wrap; +} + +.auction-modal-error { + color: var(--hd-red-deep); + background: #fff8f7; + border: 1px solid #f5d0cd; + border-radius: 10px; + padding: 8px 10px; + font-size: 14px; + margin: 0 0 10px; + font-family: "Saira", sans-serif; +} + +.auction-modal-error[hidden] { + display: none !important; +} + +/* Admin — same cards/typography as public auction */ +.admin-page { + text-align: left; +} + +.admin-page .admin-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 8px; + text-align: left; +} + +.admin-page .admin-header .section-title { + text-align: left; + margin-bottom: 8px; +} + +.admin-page .auction-lede { + font-size: 16px; + color: var(--hd-body); + max-width: 36rem; + margin: 0; + font-family: "Saira", sans-serif; + line-height: 1.45; +} + +.admin-header-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.admin-toolbar { + margin: 16px 0 18px; +} + +.admin-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.95fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .admin-layout { + grid-template-columns: 1fr; + } +} + +.admin-table-wrap { + overflow-x: auto; + background: #fff; + border: 3px solid #fff; + border-radius: 15px; + box-shadow: var(--hd-card-shadow); +} + +.admin-table, +.admin-bids-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + font-family: "Saira", sans-serif; +} + +.admin-table th, +.admin-table td, +.admin-bids-table th, +.admin-bids-table td { + text-align: left; + padding: 11px 14px; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; +} + +.admin-table th, +.admin-bids-table th { + font-family: "Rajdhani", sans-serif; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: #888; + background: #fafafa; +} + +.admin-row.is-selected { + background: #fff8f4; +} + +.admin-status { + font-family: "Rajdhani", sans-serif; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--hd-red-deep); +} + +.admin-editor { + background: #fff; + border: 3px solid #fff; + border-radius: 15px; + box-shadow: var(--hd-card-shadow); + padding: 20px 22px 24px; +} + +.admin-editor h2, +.admin-editor h3 { + font-family: "Rajdhani", sans-serif; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--hd-ink); + margin: 0 0 14px; + font-size: 18px; +} + +.admin-editor h3 { + color: var(--hd-red-deep); + font-size: 15px; +} + +.admin-editor textarea { + resize: vertical; + min-height: 72px; +} + +.admin-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +@media (max-width: 500px) { + .admin-form-row { + grid-template-columns: 1fr; + } +} + +.admin-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.admin-bids-block { + margin-top: 22px; + padding-top: 16px; + border-top: 1px solid #eee; +} + +.admin-flash { + background: #e8f6f1; + border: 1px solid #b5e0d0; + color: #018669; + padding: 10px 14px; + border-radius: 12px; + margin-bottom: 14px; + font-size: 14px; + font-family: "Saira", sans-serif; + text-align: left; +} + +.admin-flash.is-error { + background: #fff8f7; + border-color: #f5d0cd; + color: var(--hd-red-deep); +} + +.admin-flash[hidden] { + display: none !important; +} + +.admin-login-form { + max-width: 400px; + margin: 20px auto 0; + background: #fff; + border: 3px solid #fff; + border-radius: 15px; + box-shadow: var(--hd-card-shadow); + padding: 22px 22px 24px; + text-align: left; +} + +.admin-edit-btn { + padding: 7px 12px !important; + font-size: 13px !important; +} + +/* Legacy class used by older markup paths */ +.auction-page { + max-width: 960px; + margin: 0 auto; + padding: 40px 20px 64px; + color: var(--hd-ink); + font-family: "Saira", sans-serif; + text-align: left; +} + +.auction-status-banner { + display: inline-block; + font-family: "Rajdhani", sans-serif; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + background: #fff; + color: #516368; + padding: 3px 10px 2px; + border-radius: 6px; + margin-bottom: 14px; + box-shadow: var(--hd-card-shadow); +} + +.auction-page h1 { + font-family: "Rajdhani", sans-serif; + font-size: 40px; + font-weight: 600; + margin-bottom: 10px; + color: var(--hd-ink); + line-height: 1.15; +} + +.auction-lede { + color: var(--hd-body); + font-size: 17px; + margin-bottom: 24px; + max-width: 40rem; + line-height: 1.45; + font-family: "Saira", sans-serif; +} diff --git a/static/css/style.css b/static/css/style.css index 1f89d09..0069aec 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1155,6 +1155,28 @@ body { border-radius: 14px; } +/* Silent Auction nav CTA — pairs with green Donate chip */ +.header-auction-button { + background: #e13838; + color: #fff !important; + padding: 0px 14px; + border-radius: 14px; + font-weight: 600 !important; +} + +#top-navigation a.header-auction-button { + margin: 0px 8px 0px 10px; +} + +.mobile-navigation-popup a.header-auction-button { + display: inline-block; + float: none; + color: #fff !important; + padding: 8px 18px; + margin: 10px auto; + line-height: 1.3; +} + .mobile-donate-button { background: #018669; color: #fff; diff --git a/static/js/auction-admin.js b/static/js/auction-admin.js new file mode 100644 index 0000000..81866ae --- /dev/null +++ b/static/js/auction-admin.js @@ -0,0 +1,511 @@ +/** + * Auction Space admin UI — /auction/admin/ + * Requires admin role session (same OTP login as bidders). + */ +(function () { + 'use strict'; + + function apiBase() { + if (typeof window.HD_AUCTION_API === 'string' && window.HD_AUCTION_API) { + return window.HD_AUCTION_API.replace(/\/$/, ''); + } + return ''; + } + + async function fetchJson(path, options) { + var opts = options || {}; + var res = await fetch(apiBase() + path, { + credentials: 'include', + headers: Object.assign( + { Accept: 'application/json' }, + opts.body ? { 'Content-Type': 'application/json' } : {}, + opts.headers || {} + ), + method: opts.method || 'GET', + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + if (res.status === 204) return null; + var data = await res.json().catch(function () { + return null; + }); + if (!res.ok) { + var msg = + (data && data.error && data.error.message) || + 'Request failed (' + res.status + ')'; + var err = new Error(msg); + err.status = res.status; + err.code = data && data.error && data.error.code; + throw err; + } + return data; + } + + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function money(v) { + if (v == null || v === '') return '—'; + var n = Number(v); + if (!isFinite(n)) return String(v); + return ( + '$' + + n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ); + } + + function toLocalInput(iso) { + if (!iso) return ''; + var d = new Date(iso); + if (!isFinite(d.getTime())) return ''; + var pad = function (n) { + return n < 10 ? '0' + n : String(n); + }; + return ( + d.getFullYear() + + '-' + + pad(d.getMonth() + 1) + + '-' + + pad(d.getDate()) + + 'T' + + pad(d.getHours()) + + ':' + + pad(d.getMinutes()) + ); + } + + function fromLocalInput(local) { + if (!local) return null; + var d = new Date(local); + return d.toISOString(); + } + + var root = document.getElementById('auction-admin'); + if (!root) return; + + var state = { + user: null, + artworks: [], + selectedId: null, + selected: null, + bids: [], + }; + + function setFlash(msg, isError) { + var el = document.getElementById('admin-flash'); + if (!el) return; + el.hidden = !msg; + el.textContent = msg || ''; + el.className = 'admin-flash' + (isError ? ' is-error' : ''); + } + + async function ensureAdmin() { + var data = await fetchJson('/api/auction/auth/session'); + state.user = data && data.user; + if (!state.user) { + renderGate('Sign in with an admin email to manage the silent auction.'); + return false; + } + if (state.user.role !== 'admin') { + renderGate( + 'Signed in as ' + + state.user.email + + ', but this account is not an admin. Set ADMIN_EMAIL and re-run npm run auction:seed-admin.' + ); + return false; + } + return true; + } + + function renderGate(message) { + root.innerHTML = + '
' + + '
Auction Admin
' + + '
' + + escapeHtml(message) + + '
' + + '
' + + '
'; + mountLogin(document.getElementById('admin-login-panel')); + } + + function mountLogin(panel) { + if (!panel) return; + panel.innerHTML = + '' + + ''; + + var emailStored = ''; + document.getElementById('admin-login-form').addEventListener('submit', async function (e) { + e.preventDefault(); + var fd = new FormData(e.target); + emailStored = String(fd.get('email') || '').trim(); + var err = document.getElementById('admin-login-error'); + err.hidden = true; + try { + var res = await fetchJson('/api/auction/auth/request-link', { + method: 'POST', + body: { + email: emailStored, + name: String(fd.get('name') || '').trim() || undefined, + }, + }); + document.getElementById('admin-login-form').hidden = true; + document.getElementById('admin-otp-form').hidden = false; + var dev = document.getElementById('admin-dev-otp'); + if (res && res.dev_otp) { + dev.hidden = false; + dev.innerHTML = 'Dev code: ' + escapeHtml(res.dev_otp) + ''; + } + } catch (ex) { + err.hidden = false; + err.textContent = ex.message; + } + }); + + document.getElementById('admin-otp-form').addEventListener('submit', async function (e) { + e.preventDefault(); + var fd = new FormData(e.target); + var err = document.getElementById('admin-otp-error'); + err.hidden = true; + try { + await fetchJson('/api/auction/auth/verify', { + method: 'POST', + body: { email: emailStored, token: String(fd.get('token') || '').trim() }, + }); + boot(); + } catch (ex) { + err.hidden = false; + err.textContent = ex.message; + } + }); + } + + function rowHtml(a) { + var selected = state.selectedId === a.id ? ' is-selected' : ''; + return ( + '' + + '' + + escapeHtml(a.title) + + '' + + '' + + escapeHtml(a.artist) + + '' + + '' + + escapeHtml(a.status) + + '' + + '' + + money(a.current_bid != null ? a.current_bid : a.starting_bid) + + '' + + '' + + escapeHtml(a.ends_at ? new Date(a.ends_at).toLocaleString() : '') + + '' + + '' + + '' + ); + } + + function emptyForm() { + return { + id: null, + title: '', + artist: '', + description: '', + imagesText: '', + starting_bid: '20.00', + minimum_increment: '5.00', + ends_at: toLocalInput(new Date(Date.now() + 7 * 86400000).toISOString()), + status: 'draft', + }; + } + + function formFromArtwork(a) { + return { + id: a.id, + title: a.title || '', + artist: a.artist || '', + description: a.description || '', + imagesText: (a.images || []).join('\n'), + starting_bid: a.starting_bid || '0.00', + minimum_increment: a.minimum_increment || '5.00', + ends_at: toLocalInput(a.ends_at), + status: a.status || 'draft', + }; + } + + function renderApp() { + var form = state.selected + ? formFromArtwork(state.selected) + : emptyForm(); + var bidsHtml = + state.bids && state.bids.length + ? '' + + state.bids + .map(function (b) { + return ( + '' + ); + }) + .join('') + + '
AmountBidderEmailWhen
' + + money(b.amount) + + '' + + escapeHtml(b.bidder_display || b.name || '') + + '' + + escapeHtml(b.email || '') + + '' + + escapeHtml(b.created_at ? new Date(b.created_at).toLocaleString() : '') + + '
' + : '

No bids on this lot.

'; + + root.innerHTML = + '
' + + '
' + + '
Admin
' + + '
Silent Auction
' + + '

Create lots, activate bidding, and close winners. Signed in as ' + + escapeHtml(state.user.email) + + '.

' + + '
' + + 'Public Gallery ' + + '' + + '
' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + (state.artworks.length + ? state.artworks.map(rowHtml).join('') + : '') + + '
TitleArtistStatusBidEnds
No lots yet. Create one.
' + + '
' + + '

' + + (form.id ? 'Edit Artwork' : 'New Artwork') + + '

' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + ' ' + + (form.id && form.status !== 'closed' + ? ' ' + : '') + + (form.id && form.status === 'draft' + ? '' + : '') + + '
' + + (form.id + ? '

Bids

' + bidsHtml + '
' + : '') + + '
'; + + wireApp(); + } + + function wireApp() { + document.getElementById('admin-logout').addEventListener('click', async function () { + await fetchJson('/api/auction/auth/session', { method: 'DELETE' }); + boot(); + }); + document.getElementById('admin-new').addEventListener('click', function () { + state.selectedId = null; + state.selected = null; + state.bids = []; + renderApp(); + }); + + root.querySelectorAll('.admin-edit-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + selectArtwork(btn.getAttribute('data-id')); + }); + }); + + document.getElementById('admin-form').addEventListener('submit', async function (e) { + e.preventDefault(); + setFlash(''); + var fd = new FormData(e.target); + var id = String(fd.get('id') || '').trim(); + var payload = { + title: String(fd.get('title') || '').trim(), + artist: String(fd.get('artist') || '').trim(), + description: String(fd.get('description') || ''), + images: String(fd.get('images') || '') + .split('\n') + .map(function (s) { + return s.trim(); + }) + .filter(Boolean), + starting_bid: String(fd.get('starting_bid') || '').trim(), + minimum_increment: String(fd.get('minimum_increment') || '').trim(), + ends_at: fromLocalInput(String(fd.get('ends_at') || '')), + status: String(fd.get('status') || 'draft'), + }; + try { + if (id) { + var patched = await fetchJson('/api/auction/artworks/' + encodeURIComponent(id), { + method: 'PATCH', + body: payload, + }); + setFlash('Saved “' + patched.artwork.title + '”.'); + state.selectedId = patched.artwork.id; + } else { + var created = await fetchJson('/api/auction/artworks', { + method: 'POST', + body: payload, + }); + setFlash('Created “' + created.artwork.title + '”.'); + state.selectedId = created.artwork.id; + } + await reloadList(); + if (state.selectedId) await selectArtwork(state.selectedId); + else renderApp(); + } catch (ex) { + setFlash(ex.message, true); + } + }); + + var closeBtn = document.getElementById('admin-close'); + if (closeBtn) { + closeBtn.addEventListener('click', async function () { + if (!state.selectedId) return; + if (!confirm('Close this auction and lock in the high bidder as winner?')) return; + try { + await fetchJson( + '/api/auction/artworks/' + encodeURIComponent(state.selectedId) + '/close', + { method: 'POST' } + ); + setFlash('Auction closed.'); + await reloadList(); + await selectArtwork(state.selectedId); + } catch (ex) { + setFlash(ex.message, true); + } + }); + } + + var delBtn = document.getElementById('admin-delete'); + if (delBtn) { + delBtn.addEventListener('click', async function () { + if (!state.selectedId) return; + if (!confirm('Delete this draft permanently?')) return; + try { + await fetchJson( + '/api/auction/artworks/' + encodeURIComponent(state.selectedId), + { method: 'DELETE' } + ); + setFlash('Draft deleted.'); + state.selectedId = null; + state.selected = null; + state.bids = []; + await reloadList(); + renderApp(); + } catch (ex) { + setFlash(ex.message, true); + } + }); + } + } + + async function reloadList() { + var data = await fetchJson('/api/auction/admin/artworks'); + state.artworks = (data && data.artworks) || []; + } + + async function selectArtwork(id) { + var data = await fetchJson( + '/api/auction/admin/artworks?id=' + encodeURIComponent(id) + ); + state.selectedId = id; + state.selected = data.artwork; + state.bids = data.bids || []; + renderApp(); + } + + async function boot() { + root.innerHTML = '
Loading admin…
'; + try { + var ok = await ensureAdmin(); + if (!ok) return; + await reloadList(); + renderApp(); + } catch (ex) { + root.innerHTML = + '
' + + escapeHtml(ex.message) + + '
'; + } + } + + boot(); +})(); diff --git a/static/js/auction.js b/static/js/auction.js new file mode 100644 index 0000000..6398d9f --- /dev/null +++ b/static/js/auction.js @@ -0,0 +1,570 @@ +/** + * Auction Space client — gallery, detail, login OTP, place bid. + * window.HD_AUCTION_API = optional API origin (no trailing slash) + */ +(function () { + 'use strict'; + + var state = { + user: null, + artwork: null, + artworkId: null, + }; + + function apiBase() { + if (typeof window.HD_AUCTION_API === 'string' && window.HD_AUCTION_API) { + return window.HD_AUCTION_API.replace(/\/$/, ''); + } + return ''; + } + + function apiUrl(path) { + return apiBase() + path; + } + + async function fetchJson(path, options) { + var opts = options || {}; + var res = await fetch(apiUrl(path), { + credentials: 'include', + headers: Object.assign( + { Accept: 'application/json' }, + opts.body ? { 'Content-Type': 'application/json' } : {}, + opts.headers || {} + ), + method: opts.method || 'GET', + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + if (res.status === 204) return null; + var data = await res.json().catch(function () { + return null; + }); + if (!res.ok) { + var msg = + (data && data.error && data.error.message) || + 'Request failed (' + res.status + ')'; + var err = new Error(msg); + err.status = res.status; + err.code = data && data.error && data.error.code; + err.payload = data; + throw err; + } + return data; + } + + function formatMoney(value) { + if (value == null || value === '') return '—'; + var n = Number(value); + if (!isFinite(n)) return String(value); + return ( + '$' + + n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + ); + } + + function formatCountdown(endsAt) { + var end = new Date(endsAt).getTime(); + if (!isFinite(end)) return { text: '—', ended: true }; + var ms = end - Date.now(); + if (ms <= 0) return { text: 'Ended', ended: true }; + var totalSec = Math.floor(ms / 1000); + var days = Math.floor(totalSec / 86400); + var hours = Math.floor((totalSec % 86400) / 3600); + var mins = Math.floor((totalSec % 3600) / 60); + var secs = totalSec % 60; + var pad = function (n) { + return n < 10 ? '0' + n : String(n); + }; + if (days > 0) { + return { + text: days + 'd ' + pad(hours) + 'h ' + pad(mins) + 'm', + ended: false, + }; + } + return { + text: pad(hours) + ':' + pad(mins) + ':' + pad(secs), + ended: false, + }; + } + + function bindCountdowns(root) { + var nodes = (root || document).querySelectorAll('[data-ends-at]'); + function tick() { + nodes.forEach(function (el) { + var c = formatCountdown(el.getAttribute('data-ends-at')); + el.textContent = c.text; + if (c.ended) el.classList.add('is-ended'); + else el.classList.remove('is-ended'); + }); + } + tick(); + if (nodes.length) setInterval(tick, 1000); + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + async function refreshSession() { + try { + var data = await fetchJson('/api/auction/auth/session'); + state.user = (data && data.user) || null; + } catch (e) { + state.user = null; + } + return state.user; + } + + /* ---------- Modal ---------- */ + + function ensureModal() { + var existing = document.getElementById('auction-modal'); + if (existing) return existing; + var wrap = document.createElement('div'); + wrap.id = 'auction-modal'; + wrap.className = 'auction-modal'; + wrap.hidden = true; + wrap.innerHTML = + '
' + + ''; + document.body.appendChild(wrap); + wrap.addEventListener('click', function (e) { + if (e.target && e.target.getAttribute('data-close') === '1') closeModal(); + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !wrap.hidden) closeModal(); + }); + return wrap; + } + + function openModal(title, bodyHtml) { + var modal = ensureModal(); + modal.querySelector('#auction-modal-title').textContent = title; + modal.querySelector('#auction-modal-body').innerHTML = bodyHtml; + modal.hidden = false; + document.body.classList.add('auction-modal-open'); + var focusable = modal.querySelector('input, button:not([data-close])'); + if (focusable) focusable.focus(); + } + + function closeModal() { + var modal = document.getElementById('auction-modal'); + if (modal) modal.hidden = true; + document.body.classList.remove('auction-modal-open'); + } + + function setModalError(msg) { + var el = document.getElementById('auction-modal-error'); + if (el) { + el.textContent = msg || ''; + el.hidden = !msg; + } + } + + /* ---------- Login flow ---------- */ + + function showLoginForm(opts) { + opts = opts || {}; + openModal( + 'Log In to Bid', + '

Enter your email. We will send a one-time code.

' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + ); + + document.getElementById('auction-login-form').addEventListener('submit', async function (e) { + e.preventDefault(); + setModalError(''); + var fd = new FormData(e.target); + var email = String(fd.get('email') || '').trim(); + var name = String(fd.get('name') || '').trim(); + var btn = e.target.querySelector('[type=submit]'); + btn.disabled = true; + try { + var res = await fetchJson('/api/auction/auth/request-link', { + method: 'POST', + body: { email: email, name: name || undefined }, + }); + showOtpForm({ + email: email, + devOtp: res && res.dev_otp, + onSuccess: opts.onSuccess, + }); + } catch (err) { + setModalError(err.message || 'Could not send code'); + btn.disabled = false; + } + }); + } + + function showOtpForm(opts) { + var hint = opts.devOtp + ? '

Dev code: ' + + escapeHtml(opts.devOtp) + + ' (email not configured)

' + : '

Check your inbox for a 6-digit code.

'; + + openModal( + 'Enter Login Code', + hint + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + ); + + document.getElementById('auction-otp-back').addEventListener('click', function () { + showLoginForm({ onSuccess: opts.onSuccess }); + }); + + document.getElementById('auction-otp-form').addEventListener('submit', async function (e) { + e.preventDefault(); + setModalError(''); + var fd = new FormData(e.target); + var token = String(fd.get('token') || '').trim(); + var btn = e.target.querySelector('[type=submit]'); + btn.disabled = true; + try { + var res = await fetchJson('/api/auction/auth/verify', { + method: 'POST', + body: { email: opts.email, token: token }, + }); + state.user = res.user; + closeModal(); + if (typeof opts.onSuccess === 'function') opts.onSuccess(res.user); + } catch (err) { + setModalError(err.message || 'Invalid code'); + btn.disabled = false; + } + }); + } + + /* ---------- Bid flow ---------- */ + + function showBidForm(art) { + var min = art.minimum_next_bid; + openModal( + 'Place a Bid', + '

' + + escapeHtml(art.title) + + '
Minimum bid: ' + + formatMoney(min) + + '

' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '

You will get an email if someone outbids you.

' + + '
' + ); + + document.getElementById('auction-bid-form').addEventListener('submit', async function (e) { + e.preventDefault(); + setModalError(''); + var fd = new FormData(e.target); + var amount = String(fd.get('amount') || '').trim(); + var btn = e.target.querySelector('[type=submit]'); + btn.disabled = true; + try { + var res = await fetchJson('/api/auction/bids', { + method: 'POST', + body: { artwork_id: art.id, amount: amount }, + }); + closeModal(); + openModal( + 'Bid Placed', + '

Your bid of ' + + formatMoney(res.bid.amount) + + ' is the current high bid.

' + + '
' + + '' + + '
' + ); + if (state.artworkId) { + var detail = document.getElementById('auction-detail'); + if (detail) mountDetail(detail, state.artworkId); + } + } catch (err) { + setModalError(err.message || 'Bid failed'); + btn.disabled = false; + } + }); + } + + function startBidFlow(art) { + if (!art) return; + if (!state.user) { + showLoginForm({ + onSuccess: function () { + showBidForm(art); + }, + }); + return; + } + showBidForm(art); + } + + /* ---------- Gallery / detail ---------- */ + + function cardHtml(art) { + var bidLabel = + art.current_bid != null + ? 'Current ' + formatMoney(art.current_bid) + : 'Starting ' + formatMoney(art.starting_bid); + var img = art.primary_image + ? '' +
+        escapeHtml(art.title) +
+        '' + : '
No image
'; + var href = '/auction/artwork/?id=' + encodeURIComponent(art.id); + return ( + '' + ); + } + + async function mountGallery(el) { + el.innerHTML = '
Loading lots…
'; + try { + var data = await fetchJson('/api/auction/artworks?status=all_public&limit=50'); + var list = (data && data.artworks) || []; + if (!list.length) { + el.innerHTML = + '
No auction lots are live yet. Check back soon.
'; + return; + } + el.innerHTML = + '
' + list.map(cardHtml).join('') + '
'; + bindCountdowns(el); + } catch (err) { + el.innerHTML = + '
Could not load auction lots. ' + + escapeHtml(err.message || 'Try again later.') + + '
'; + } + } + + function statusPill(status) { + var cls = 'auction-status-pill'; + if (status === 'closed' || status === 'preview') cls += ' is-' + status; + return ( + '' + + escapeHtml(status || 'unknown') + + '' + ); + } + + function relativeTime(iso) { + var t = new Date(iso).getTime(); + if (!isFinite(t)) return ''; + var sec = Math.round((Date.now() - t) / 1000); + if (sec < 60) return 'just now'; + if (sec < 3600) return Math.floor(sec / 60) + 'm ago'; + if (sec < 86400) return Math.floor(sec / 3600) + 'h ago'; + return Math.floor(sec / 86400) + 'd ago'; + } + + async function mountDetail(el, id) { + state.artworkId = id; + el.innerHTML = '
Loading artwork…
'; + try { + await refreshSession(); + var data = await fetchJson( + '/api/auction/artworks/' + encodeURIComponent(id) + ); + var art = data.artwork; + state.artwork = art; + var bids = data.bids || []; + var img = + art.images && art.images[0] + ? '' +
+            escapeHtml(art.title) +
+            '' + : '
No image
'; + + var current = + art.current_bid != null + ? formatMoney(art.current_bid) + : formatMoney(art.starting_bid); + var currentLabel = art.current_bid != null ? 'Current bid' : 'Starting bid'; + + var bidRows = + bids.length === 0 + ? '

No bids yet — be the first!

' + : '
    ' + + bids + .map(function (b) { + return ( + '
  • ' + + formatMoney(b.amount) + + ' · ' + + escapeHtml(b.bidder_display || 'Bidder') + + '' + + escapeHtml(relativeTime(b.created_at)) + + '
  • ' + ); + }) + .join('') + + '
'; + + var canBid = art.status === 'active' && new Date(art.ends_at) > new Date(); + var sessionLine = state.user + ? '

Signed in as ' + + escapeHtml(state.user.email) + + ' ·

' + : '

Not signed in

'; + + var cta = canBid + ? '' + : ''; + + el.innerHTML = + '← Back to Auction' + + '
' + + '
' + + img + + '
' + + '
' + + statusPill(art.status) + + '

' + + escapeHtml(art.title) + + '

' + + '
' + + escapeHtml(art.artist) + + '
' + + '
' + + '
' + + currentLabel + + '' + + current + + '
' + + '
Minimum next bid' + + formatMoney(art.minimum_next_bid) + + '
' + + '
Ends in
' + + '
' + + '
' + + escapeHtml(art.description || '') + + '
' + + sessionLine + + cta + + '

Recent Bids

' + + bidRows + + '
' + + '
'; + + bindCountdowns(el); + + var placeBtn = document.getElementById('auction-place-bid'); + if (placeBtn) { + placeBtn.addEventListener('click', function () { + startBidFlow(state.artwork); + }); + } + var logoutBtn = document.getElementById('auction-logout'); + if (logoutBtn) { + logoutBtn.addEventListener('click', async function () { + try { + await fetchJson('/api/auction/auth/session', { method: 'DELETE' }); + } catch (e) { + /* ignore */ + } + state.user = null; + mountDetail(el, id); + }); + } + } catch (err) { + el.innerHTML = + '← Back to Auction' + + '
' + + escapeHtml(err.message || 'Artwork not found') + + '
'; + } + } + + async function init() { + var gallery = document.getElementById('auction-gallery'); + if (gallery) mountGallery(gallery); + + var detail = document.getElementById('auction-detail'); + if (detail) { + var params = new URLSearchParams(window.location.search); + var id = params.get('id') || detail.getAttribute('data-artwork-id'); + if (id) mountDetail(detail, id); + else { + detail.innerHTML = + '
Missing artwork id. Back to gallery
'; + } + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + window.HDAuction = { + fetchJson: fetchJson, + formatMoney: formatMoney, + formatCountdown: formatCountdown, + bindCountdowns: bindCountdowns, + refreshSession: refreshSession, + startBidFlow: startBidFlow, + }; +})(); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..bc74d18 --- /dev/null +++ b/vercel.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "version": 2, + "functions": { + "api/**/*.js": { + "memory": 256, + "maxDuration": 30 + } + }, + "crons": [ + { + "path": "/api/auction/cron/ending-soon", + "schedule": "0 * * * *" + } + ] +}