From af5e68758e7e767ef0855bba3e7e97c75782f18c Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 4 Aug 2026 09:52:53 -0700 Subject: [PATCH 1/8] docs: Auction Space planning package and decision log (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Silent Auction MVP architecture and planning * docs: expand Silent Auction roadmap across phases 0–4 * docs: slice Phase 1 into minimal fully functional build path * docs: add Auction Space planning package and decision log Bring Silent Auction / Auction Space docs onto the fork, brand the package as Auction Space, and add DECISIONS.md so Phase 0 can close once reviewers record DB, auth, email, and API host choices. Co-authored-by: Daniel Meyer --------- Co-authored-by: Cursor Agent --- README.md | 5 + docs/auction/ACCEPTANCE.md | 72 ++++++++++ docs/auction/API.md | 262 ++++++++++++++++++++++++++++++++++ docs/auction/ARCHITECTURE.md | 160 +++++++++++++++++++++ docs/auction/CURRENT_STATE.md | 103 +++++++++++++ docs/auction/DATA_MODEL.md | 118 +++++++++++++++ docs/auction/DECISIONS.md | 65 +++++++++ docs/auction/EMAILS.md | 75 ++++++++++ docs/auction/README.md | 115 +++++++++++++++ docs/auction/REQUIREMENTS.md | 78 ++++++++++ docs/auction/ROADMAP.md | 236 ++++++++++++++++++++++++++++++ docs/auction/SECURITY.md | 84 +++++++++++ docs/auction/SLICES.md | 168 ++++++++++++++++++++++ docs/auction/START_HERE.md | 48 +++++++ docs/auction/TODO.md | 130 +++++++++++++++++ docs/auction/UI.md | 119 +++++++++++++++ 16 files changed, 1838 insertions(+) create mode 100644 docs/auction/ACCEPTANCE.md create mode 100644 docs/auction/API.md create mode 100644 docs/auction/ARCHITECTURE.md create mode 100644 docs/auction/CURRENT_STATE.md create mode 100644 docs/auction/DATA_MODEL.md create mode 100644 docs/auction/DECISIONS.md create mode 100644 docs/auction/EMAILS.md create mode 100644 docs/auction/README.md create mode 100644 docs/auction/REQUIREMENTS.md create mode 100644 docs/auction/ROADMAP.md create mode 100644 docs/auction/SECURITY.md create mode 100644 docs/auction/SLICES.md create mode 100644 docs/auction/START_HERE.md create mode 100644 docs/auction/TODO.md create mode 100644 docs/auction/UI.md diff --git a/README.md b/README.md index bf6454c..6cdb5fb 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,8 @@ Once all pre-requisites are installed, you can preview the website using: ```sh jekyll serve ``` + +## Auction Space (Silent Auction) — Planning + +Planning docs for the fundraising silent auction live in [docs/auction](./docs/auction). +Start at [docs/auction/START_HERE.md](./docs/auction/START_HERE.md). Infrastructure choices: [docs/auction/DECISIONS.md](./docs/auction/DECISIONS.md). diff --git a/docs/auction/ACCEPTANCE.md b/docs/auction/ACCEPTANCE.md new file mode 100644 index 0000000..3f0fc0f --- /dev/null +++ b/docs/auction/ACCEPTANCE.md @@ -0,0 +1,72 @@ +# Acceptance Criteria — Silent Auction MVP + +Every MVP feature maps to **Requirement → Implementation → Verification**. + +## F1 — Artwork listing + +| | | +|--|--| +| **Requirement** | Public gallery lists active lots with title, artist, image, bid, time remaining ([REQUIREMENTS.md](./REQUIREMENTS.md) F1) | +| **Implementation** | Jekyll `/auction/` page + `GET /api/auction/artworks` | +| **Verification** | Given ≥2 `active` artworks, gallery shows both; `draft` absent; mobile single-column usable | + +## F2 — Artwork page + +| | | +|--|--| +| **Requirement** | Detail page with images, copy, bids, countdown, bid CTA (F2) | +| **Implementation** | Artwork page + `GET /api/auction/artworks/:id` | +| **Verification** | Open lot by id; fields match DB; recent bids list amounts; CTA hidden/disabled when closed | + +## F3 — Bid + +| | | +|--|--| +| **Requirement** | Authenticated bid with increment rules (F3) | +| **Implementation** | Auth session + `POST /api/auction/bids` + transactional DB update | +| **Verification** | Bid at minimum next succeeds; lower amount returns `BID_TOO_LOW`; after `ends_at` returns `AUCTION_CLOSED`; concurrent two bids → only one highest wins, other gets conflict/too low | + +## F4 — Countdown + +| | | +|--|--| +| **Requirement** | Visible countdown; server authoritative (F4) | +| **Implementation** | Client timer from `ends_at`; server enforces on POST | +| **Verification** | Timer reaches zero on page; bid attempt afterward fails even if UI lag | + +## F5 — Admin + +| | | +|--|--| +| **Requirement** | Admin CRUD, bid view, close (F5) | +| **Implementation** | `/auction/admin/` + admin API routes | +| **Verification** | Non-admin gets 403; admin creates draft → activates → sees bids → closes → winner set | + +## F6 — Email notifications + +| | | +|--|--| +| **Requirement** | Emails per [EMAILS.md](./EMAILS.md) (F6) | +| **Implementation** | Provider helper + Notification rows + cron for ending soon | +| **Verification** | Place bid → bidder receives `bid_received`; previous leader receives `outbid`; close → `winner` + `auction_closed`; within ending window → single `auction_ending_soon` | + +## Nonfunctional checks + +| ID | Verification | +|----|--------------| +| N1 Accessibility | Keyboard can open bid modal and submit; inputs labeled; images have alt | +| N2 Maintainability | No new SPA framework; handlers live under `api/`; docs still accurate | +| N3 Simplicity | No WebSocket/Redis/queue dependencies added | +| 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) + +| Check | 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 | +| 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/API.md b/docs/auction/API.md new file mode 100644 index 0000000..ef3117d --- /dev/null +++ b/docs/auction/API.md @@ -0,0 +1,262 @@ +# API — Silent Auction MVP + +REST-ish JSON over the existing serverless `api/` style. +Base path proposal: `/api/auction`. + +Auth: session cookie (see SECURITY.md) unless reviewer picks bearer tokens. + +## Conventions + +- Request/response: `application/json` +- Money: decimal strings with 2 places (e.g. `"25.00"`) to avoid float issues +- Timestamps: ISO-8601 UTC +- Errors: + +```json +{ + "error": { + "code": "BID_TOO_LOW", + "message": "Bid must be at least 35.00" + } +} +``` + +### Common HTTP status codes + +| Status | When | +|--------|------| +| 200 | OK | +| 201 | Created | +| 400 | Validation error | +| 401 | Not authenticated | +| 403 | Authenticated but not allowed | +| 404 | Missing resource | +| 409 | Conflict (e.g. outbid race lost, already closed) | +| 429 | Rate limited | +| 405 | Wrong method (match waitlist style) | +| 500 | Unexpected server error | + +--- + +## GET + +### `GET /api/auction/artworks` + +List public artworks. + +**Query:** `status=active` (default), optional `limit`, `offset` + +**Response 200** + +```json +{ + "artworks": [ + { + "id": "art_123", + "title": "Torii at Dusk", + "artist": "A. Maker", + "primary_image": "https://…/1.jpg", + "starting_bid": "20.00", + "current_bid": "30.00", + "minimum_increment": "5.00", + "minimum_next_bid": "35.00", + "ends_at": "2026-09-01T23:59:59Z", + "status": "active" + } + ] +} +``` + +### `GET /api/auction/artworks/:id` + +Artwork detail + recent bids. + +**Response 200** + +```json +{ + "artwork": { + "id": "art_123", + "title": "Torii at Dusk", + "artist": "A. Maker", + "description": "…", + "images": ["https://…/1.jpg", "https://…/2.jpg"], + "starting_bid": "20.00", + "current_bid": "30.00", + "minimum_increment": "5.00", + "minimum_next_bid": "35.00", + "ends_at": "2026-09-01T23:59:59Z", + "status": "active" + }, + "bids": [ + { "amount": "30.00", "created_at": "2026-08-20T18:01:00Z", "bidder_display": "Jamie" } + ] +} +``` + +Bidder PII: prefer first name / masked email; never expose full email to other bidders. + +### `GET /api/auction/artworks/:id/bids` + +Bid history for artwork (public amounts; privacy-safe display names). + +### `GET /api/auction/admin/artworks` **admin** + +All statuses; includes draft. + +### `GET /api/auction/me` **auth** + +```json +{ "user": { "id": "usr_1", "email": "a@b.c", "name": "Jamie", "role": "bidder" } } +``` + +--- + +## POST + +### `POST /api/auction/auth/request-link` + +Start magic-link / OTP login. + +**Body** + +```json +{ "email": "jamie@example.com", "name": "Jamie" } +``` + +**Response 200** `{ "ok": true }` (always generic to avoid account enumeration where practical) + +### `POST /api/auction/auth/verify` + +**Body** + +```json +{ "email": "jamie@example.com", "token": "…" } +``` + +**Response 200** sets session cookie + `{ "user": { … } }` + +### `POST /api/auction/bids` **auth** + +Place a bid. + +**Body** + +```json +{ + "artwork_id": "art_123", + "amount": "35.00" +} +``` + +(`auction_id` accepted as alias for `artwork_id`) + +**Response 201** + +```json +{ + "bid": { + "id": "bid_9", + "artwork_id": "art_123", + "amount": "35.00", + "created_at": "2026-08-20T19:00:00Z" + }, + "artwork": { + "current_bid": "35.00", + "minimum_next_bid": "40.00", + "ends_at": "2026-09-01T23:59:59Z", + "status": "active" + } +} +``` + +**Errors:** `BID_TOO_LOW`, `AUCTION_CLOSED`, `AUCTION_NOT_ACTIVE`, `INVALID_AMOUNT` + +### `POST /api/auction/artworks` **admin** + +**Body** + +```json +{ + "title": "Torii at Dusk", + "artist": "A. Maker", + "description": "Ink on paper", + "images": ["https://…/1.jpg"], + "starting_bid": "20.00", + "minimum_increment": "5.00", + "ends_at": "2026-09-01T23:59:59Z", + "status": "draft" +} +``` + +**Response 201** `{ "artwork": { … } }` + +### `POST /api/auction/artworks/:id/close` **admin** + +Close lot; set winner to current high bidder if any. + +**Response 200** `{ "artwork": { "status": "closed", "winner_user_id": "usr_…" } }` + +### `POST /api/auction/cron/ending-soon` **server secret** + +Invoked by scheduler. Finds active lots ending within configured window; sends `auction_ending_soon` once per user/artwork. + +Protect with shared secret header, not public session. + +--- + +## PATCH + +### `PATCH /api/auction/artworks/:id` **admin** + +Partial update of artwork fields (title, artist, description, images, starting_bid, minimum_increment, ends_at, status). + +Rules: + +- Do not lower `starting_bid` below `current_bid` after bids exist. +- Changing `status` to `active` requires valid `ends_at` in the future. + +**Response 200** `{ "artwork": { … } }` + +### `PATCH /api/auction/me` **auth** + +```json +{ "name": "Jamie Q." } +``` + +--- + +## DELETE + +### `DELETE /api/auction/artworks/:id` **admin** + +Allowed only if `status = draft` and zero bids. Otherwise `409` with `ARTWORK_NOT_DELETABLE`. + +Soft-alternative: set `status = closed` via PATCH/close — preferred for artworks with history. + +### `DELETE /api/auction/auth/session` **auth** + +Log out; clear cookie. **Response 204** + +--- + +## Error code catalog (MVP) + +| code | HTTP | Meaning | +|------|------|---------| +| VALIDATION_ERROR | 400 | Missing/invalid fields | +| INVALID_AMOUNT | 400 | Not a positive money amount | +| BID_TOO_LOW | 409 | Below minimum next bid | +| AUCTION_CLOSED | 409 | Past ends_at or closed | +| AUCTION_NOT_ACTIVE | 409 | Wrong status | +| UNAUTHENTICATED | 401 | Login required | +| FORBIDDEN | 403 | Admin or owner required | +| NOT_FOUND | 404 | Unknown id | +| ARTWORK_NOT_DELETABLE | 409 | Has bids / not draft | +| RATE_LIMITED | 429 | Too many attempts | +| SERVER_ERROR | 500 | Unexpected | + +## Notes + +- CSRF: for cookie sessions, require `SameSite` cookie + origin check or CSRF token on POSTs. +- Idempotency: optional `Idempotency-Key` header on `POST /bids` in a fast-follow if double-submit appears in testing. diff --git a/docs/auction/ARCHITECTURE.md b/docs/auction/ARCHITECTURE.md new file mode 100644 index 0000000..0f29171 --- /dev/null +++ b/docs/auction/ARCHITECTURE.md @@ -0,0 +1,160 @@ +# Architecture — Silent Auction MVP + +Design constrained by the audited repository. Existing pieces are reused; gaps are labeled **NEW (required)**. + +## Principles + +- Do not redesign the marketing site. +- Prefer Jekyll pages + existing layout/CSS. +- Extend the existing `api/` serverless handler style. +- No WebSockets, Redis, queues, or microservices. +- Introduce only the minimum backend needed for durable bids, auth, and email. + +## System diagram + +```text +Browser + | Jekyll HTML (GitHub Pages) + | GET /auction/ GET /auction/:id/ + | admin HTML pages + v ++---------------------------+ +----------------------+ +| Static site (existing) | | Serverless API (ext) | +| Jekyll + static/css/js | ------> | api/auction/*.js | ++---------------------------+ fetch | (same style as | + | api/waitlist.js) | + +----------+-----------+ + | + +-----------------------+-----------------------+ + | | | + v v v + +-------------+ +-------------+ +-------------+ + | Database | | Auth/session| | Email | + | NEW | | NEW | | NEW | + +-------------+ +-------------+ +-------------+ +``` + +## Frontend + +**Existing** + +- Jekyll static pages, `_layouts/default.html`, `_includes/header.html` / `footer.html` +- `static/css/style.css`, site fonts, small `static/js/functions.js` +- Optional page-local Tailwind CDN pattern (seen on waitlist) — use only if it speeds admin/forms without a new build pipeline + +**MVP additions (proposed)** + +| Page | Role | +|------|------| +| `/auction/` | Gallery (F1) | +| `/auction/artwork/` or `/auction/:slug/` | Detail + countdown + bid CTA (F2–F4) | +| `/auction/admin/` | Admin list/create/edit (F5) | + +Rendering: **server-rendered HTML via Jekyll** for chrome and static structure; dynamic bid/current price loaded from API (same general approach as waitlist form → `/api/...`). + +No SPA framework. No site-wide React/Vue. + +## Backend + +**Existing pattern:** `api/waitlist.js` default export handler with method checks and JSON in/out. + +**MVP:** Add handlers under `api/` (exact filenames free within this pattern), for example: + +- `api/auction/artworks.js` +- `api/auction/bids.js` +- `api/auction/admin.js` +- `api/auction/auth.js` (if magic-link/session lives here) + +Keep handlers thin: validate → authorize → DB → optional email → JSON response. + +**Not used:** background workers, separate auction microservice, GraphQL. + +## Database + +**Existing:** none in-repo. + +**NEW (required):** one relational database (reviewer choice). Candidates for decision — not prescriptions: + +- Hosted Postgres (simplest for bid integrity / transactions) +- Or another durable store explicitly approved in PR review + +Airtable is **observed** for waitlist intake only. It is a weak fit for concurrent bid increments; do not default auction bids to Airtable unless reviewers explicitly accept the integrity tradeoffs. + +Schema: see [DATA_MODEL.md](./DATA_MODEL.md). + +## Email + +**Existing:** none (waitlist only stores email via Airtable; does not send mail from this repo). + +**NEW (required):** one transactional email provider (reviewer choice). Send from API handlers or a single shared `lib/email` helper used by those handlers. + +Templates: [EMAILS.md](./EMAILS.md). +Record rows in `Notification` for audit / dedupe. + +**Ending soon:** implement with a simple scheduled invoke (platform cron hitting an API route) **or** check-on-read that enqueues send once — prefer the hosting platform’s native cron if available. Do not add Redis/queues. + +## Authentication + +**Existing in-repo:** none. External Nexudus links only; no API integration observed. + +**NEW (required for MVP bidding + admin):** + +| Actor | Approach (proposed minimum) | +|-------|-----------------------------| +| Bidder | Email magic link or email + one-time code → HTTP-only session cookie | +| Admin | Same auth, `role = admin` (seeded allowlist of emails) | + +Do **not** claim Nexudus reuse until CURRENT_STATE unknowns are resolved. If later Nexudus SSO is approved, migrate session issuance — keep User table as the app identity. + +“Reuse existing User model” from the product brief: **there is no User model to reuse**. MVP introduces a minimal User table (see DATA_MODEL.md) and must not duplicate persons across multiple local user stores. + +## Deployment + +**Observed today** + +- Static site → GitHub Pages (`hackerdojo.org`) +- `api/waitlist.js` suggests a Vercel-compatible serverless host (unconfirmed config in-repo) + +**MVP deployment shape (proposed)** + +```text +GitHub repo + ├── Jekyll build → GitHub Pages (unchanged path) + └── api/* → serverless host (confirm/document in implementation PR) +``` + +Implementation PR must check in whatever host config is required (e.g. `vercel.json` if Vercel is confirmed) so auction APIs are reproducible. + +## Simple request flows + +### Place bid + +```text +User (session) → POST /api/auction/bids + → authn + → load artwork (row lock / transactional update) + → validate amount & ends_at & status + → insert Bid; update current_bid + → send bid_received + outbid emails + → return 201 + artwork summary +``` + +### Admin create artwork + +```text +Admin (session) → POST /api/auction/artworks + → require role=admin + → validate payload + → insert Artwork + → return 201 +``` + +## Explicit non-architecture + +| Rejected | Reason | +|----------|--------| +| WebSockets / SSE live board | Out of scope; refresh/fetch enough | +| Redis | Not in repo; not needed for MVP | +| Message queue | Not in repo; emails send inline/simple retry | +| Microservices | Violates simplicity | +| New SPA | Violates reuse / static site fit | diff --git a/docs/auction/CURRENT_STATE.md b/docs/auction/CURRENT_STATE.md new file mode 100644 index 0000000..ee6ed72 --- /dev/null +++ b/docs/auction/CURRENT_STATE.md @@ -0,0 +1,103 @@ +# Current State — Repository Audit + +Audit of [hd-admin/hackerdojo.org](https://github.com/hd-admin/hackerdojo.org) (`main` at documentation time). +Findings are separated into **OBSERVED**, **INFERRED**, and **UNKNOWN**. No speculative architecture is treated as fact. + +## Current repository (summary) + +| Area | Finding | +|------|---------| +| Framework | **OBSERVED:** Jekyll `~> 4.3` (Ruby), `minima` theme gem, `jekyll-feed` plugin | +| Routing | **OBSERVED:** File-based static HTML pages with Jekyll front matter (`layout`, `title`); includes under `_includes/`, layouts under `_layouts/` | +| ORM / database | **OBSERVED:** None in repository | +| Auth | **OBSERVED:** No in-repo authentication or session system. Header/footer link to external Nexudus member portal (`hackerdojo.spaces.nexudus.com`) | +| Email system | **OBSERVED:** No outbound email sender, templates, or mailer library in repository | +| UI framework | **OBSERVED:** Custom HTML + `static/css/style.css`; Google Fonts (Rajdhani, Saira); Swiper; page-local Tailwind CDN on `priority-waitlist.html` | +| Testing framework | **OBSERVED:** No test directory, test runner, or CI test workflow | +| 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 | +| CI workflows | **OBSERVED:** `.github/CODEOWNERS` only; no `.github/workflows/` | + +## OBSERVED + +### Stack and structure + +- Root `Gemfile` / `Gemfile.lock` pin Jekyll 4.3.x and related gems. +- Local preview scripts: `start.sh` (`bundle exec jekyll serve --livereload --trace`), `devstart.sh` (`bundle exec jekyll serve`). +- Site shell: `_layouts/default.html` wraps content with `_includes/header.html` and `_includes/footer.html`. +- Homepage composes sections via `{% include_relative pages/home/... %}`. +- Static assets under `static/` (CSS, JS, images, vendored Swiper). +- Languages (GitHub API): primarily HTML, then CSS, JavaScript, small Ruby/Shell. + +### Integration points present today + +1. **`api/waitlist.js`** — Serverless POST handler; CORS open (`*`); forwards JSON to Airtable workflow webhook; returns upstream JSON. +2. **`priority-waitlist.html`** — Client form posts to `/api/waitlist`; comment in page source refers to a “Vercel rewrite”. +3. **External membership** — Links to Nexudus login / public membership pages. +4. **Fundraising** — Donate links to FundRazr (external). +5. **Analytics** — Google gtag `AW-880343912` in header. +6. **CODEOWNERS** — Requires review by listed owners for all changes. + +### What is not present + +- No User / Account model or schema files. +- No database client, migrations, Prisma/ActiveRecord/SQL files. +- No session cookies, JWT helpers, OAuth callbacks, or password flows in-repo. +- No Resend/SendGrid/Postmark/SMTP/Nodemailer (or similar) integration. +- No Redis, queue workers, WebSocket servers. +- No `vercel.json`, `netlify.toml`, or `_config.yml` in the repository tree at audit time. +- No automated tests. + +## INFERRED + +These are reasonable conclusions from observed code, not confirmed configuration. + +| Inference | Basis | +|-----------|--------| +| Marketing site is primarily static and built/served via GitHub Pages | `CNAME`, repeated `github-pages` deployments, Jekyll structure | +| Waitlist API is intended to run as a Vercel (or Vercel-compatible) serverless function | Handler signature + page comment about “Vercel rewrite”; path `/api/waitlist` | +| Dual hosting may be in play (Pages for static, serverless host for `api/`) | Static Pages deployments + `api/*.js` pattern without Pages Functions config in-repo | +| Tailwind is optional/page-local, not a site-wide build dependency | CDN script only on waitlist page; main site uses custom CSS | +| Member identity lives in Nexudus, not this repo | Login links only; no Nexudus API usage found in-repo | + +## UNKNOWN + +| Question | Why it matters for auction MVP | +|----------|--------------------------------| +| Is there an active Vercel (or other) project wired to this repo for `api/`? | Determines where bid APIs should deploy | +| Can GitHub Pages alone serve `/api/*`, or must serverless hosting be confirmed? | Deployment architecture | +| Is Nexudus available as an auth provider for bidders (API/SSO)? | Whether “reuse existing auth” can mean Nexudus vs new light auth | +| Airtable base ownership / whether auction data should live in Airtable vs a real DB | Data store choice; waitlist already uses Airtable webhooks | +| Who operates production secrets (webhook URLs, future API keys)? | Security and ops | +| Preferred email provider for transactional mail | EMAILS.md implementation | +| Whether `dojo-earth.vercel.app` or other Dojo apps share auth/DB we should reuse | Avoid duplicate users if a shared User store exists elsewhere | + +## Integration points for Silent Auction (planning) + +| Existing piece | Reuse approach | +|----------------|----------------| +| Jekyll pages + default layout | Add auction gallery / artwork / admin pages as static (or lightly dynamic) pages | +| `static/css/style.css` + fonts | Match site look; avoid site redesign | +| `api/*.js` handler style | Add bid/admin API handlers beside `waitlist.js` | +| Airtable webhook pattern | Optional for ops notifications only — **not** assumed as system of record for bids unless approved | +| Nexudus | Do **not** assume reusable auth until UNKNOWN is resolved | +| CODEOWNERS | All auction PRs still require owner review | + +## Risks + +1. **Infrastructure gap:** MVP needs User identity, durable storage, and email — none exist in-repo. Implementation will introduce new dependencies; this must be an explicit, reviewed decision. +2. **Hosting ambiguity:** Static Pages + serverless `api/` without checked-in host config risks “works locally / fails in prod”. +3. **Auth gap vs prompt expectation:** Planning docs that say “reuse existing User model” cannot literally do so — there is no User model here. See DATA_MODEL.md. +4. **Concurrency:** Silent auction bids need race-safe “highest bid wins” updates; a static site alone cannot provide this. +5. **Public webhook pattern:** Current waitlist proxies to a hardcoded Airtable webhook URL in source — auction must not copy secrets into the client or commit durable credentials carelessly. + +## Unknowns that block implementation (not documentation) + +- Approved database +- Approved auth mechanism +- 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). diff --git a/docs/auction/DATA_MODEL.md b/docs/auction/DATA_MODEL.md new file mode 100644 index 0000000..359c1d9 --- /dev/null +++ b/docs/auction/DATA_MODEL.md @@ -0,0 +1,118 @@ +# Data Model — Silent Auction MVP + +MVP-only schema. Prefer one relational database with transactions. + +## Important gap: User + +The product brief says “Reuse existing User model. Do not duplicate users.” + +**OBSERVED:** This repository has **no User model** (see CURRENT_STATE.md). + +**MVP decision:** Introduce a single minimal `User` table as the identity source for bidders and admins. Do not create a second parallel user store inside the auction feature. If a shared Dojo identity store is later approved, migrate to it rather than duplicating. + +## Entity relationship + +```text +User 1 --- * Bid +User 1 --- * Notification +Artwork 1 --- * Bid +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. + +## Tables + +### User (**NEW** — required) + +| Column | Type | Notes | +|--------|------|-------| +| id | uuid / bigserial PK | | +| email | citext/text unique not null | Login identifier | +| name | text null | Display name | +| role | text not null default `bidder` | `bidder` \| `admin` | +| created_at | timestamptz not null | | + +Optional later (not MVP-required): `email_verified_at`, password hash (only if magic-link rejected). + +### Artwork + +| Column | Type | Notes | +|--------|------|-------| +| id | uuid / bigserial PK | | +| title | text not null | | +| artist | text not null | | +| description | text not null default `''` | | +| images | jsonb/text[] not null default `[]` | URLs or storage keys; first = primary | +| starting_bid | numeric(12,2) not null | >= 0 | +| current_bid | numeric(12,2) null | Null until first accepted bid; then max bid | +| minimum_increment | numeric(12,2) not null | > 0 | +| ends_at | timestamptz not null | | +| status | text not null | `draft` \| `preview` \| `active` \| `closed` | +| created_at | timestamptz not null | | +| updated_at | timestamptz not null | | +| winner_user_id | fk User null | Set when closed with bids | +| created_by | fk User null | Admin who created | + +**Minimum next bid (derived, not stored):** + +- If `current_bid` is null → `starting_bid` +- Else → `current_bid + minimum_increment` + +### Bid + +| Column | Type | Notes | +|--------|------|-------| +| id | uuid / bigserial PK | | +| artwork_id | fk Artwork not null | Brief name: `auction_id` | +| user_id | fk User not null | | +| amount | numeric(12,2) not null | | +| created_at | timestamptz not null | | + +Indexes: + +- `(artwork_id, amount DESC, created_at DESC)` +- `(user_id, created_at DESC)` + +### Notification + +| Column | Type | Notes | +|--------|------|-------| +| id | uuid / bigserial PK | | +| user_id | fk User not null | | +| type | text not null | See EMAILS.md type keys | +| artwork_id | fk Artwork null | Context when applicable | +| bid_id | fk Bid null | Context when applicable | +| sent_at | timestamptz null | Null = pending/failed; set on success | +| created_at | timestamptz not null | | +| meta | jsonb null | Optional template vars / provider id | + +Unique-ish guard for idempotent “ending soon”: unique `(type, user_id, artwork_id)` where `type = auction_ending_soon` (partial unique index if supported). + +## Status lifecycle (Artwork) + +```text +draft → preview → active → closed + ↘_________↗ +``` + +- `draft`: admin only +- `preview`: public visible, bidding disabled +- `active`: public + bidding until `ends_at` +- `closed`: no bids; winner may be set + +## Integrity rules + +1. Accepted bid `amount` must be ≥ minimum next bid at commit time. +2. Reject if `status != active` or `now() >= ends_at`. +3. Update `current_bid` in the **same transaction** as insert Bid. +4. Prefer `SELECT … FOR UPDATE` on Artwork row (or equivalent) to prevent lost updates. +5. Users are never duplicated per email. + +## What we are not modeling in MVP + +- Payment intents / invoices +- Shipping addresses +- Watchlists (optional later) +- Soft-delete / archive beyond `status` +- Multi-lot “Event” parent (each Artwork is the lot) diff --git a/docs/auction/DECISIONS.md b/docs/auction/DECISIONS.md new file mode 100644 index 0000000..8110664 --- /dev/null +++ b/docs/auction/DECISIONS.md @@ -0,0 +1,65 @@ +# 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 | _pending_ | | | Must support transactional row locks for bids | +| D2 | Auth method | _pending_ | | | Default proposal: email magic-link / OTP + HTTP-only session cookie | +| D3 | Email provider | _pending_ | | | Transactional only (bid / outbid / winner / closed / ending soon) | +| D4 | API host | _pending_ | | | Must run `api/*.js` serverless handlers beside waitlist | + +### 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` | DB connection string | D1 | +| `SESSION_SECRET` | Sign/encrypt session cookies | D2 | +| `ADMIN_EMAIL` | Seed admin user | Slice 0 | +| `EMAIL_API_KEY` | Provider API key | D3 | +| `EMAIL_FROM` | From address for auction mail | D3 | +| `AUCTION_CRON_SECRET` | Authorize ending-soon cron | Slice 4 | +| `CORS_ORIGIN` | Allowed browser origin(s) | Slice 5 | + +Add provider-specific vars (e.g. `RESEND_API_KEY`) 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. + +--- + +## 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 | diff --git a/docs/auction/EMAILS.md b/docs/auction/EMAILS.md new file mode 100644 index 0000000..3eada29 --- /dev/null +++ b/docs/auction/EMAILS.md @@ -0,0 +1,75 @@ +# Emails — Silent Auction MVP + +Transactional emails only. Provider is **NEW** (none in repo today). +Each successful send should write/update a `Notification` row (`type`, `user_id`, `sent_at`). + +## 1. Bid received + +| Field | Value | +|-------|--------| +| Type key | `bid_received` | +| Trigger | Bid accepted | +| Recipient | Bidding user | +| Subject | `Bid received: {{artwork_title}}` | + +**Variables:** `bidder_name`, `artwork_title`, `artwork_url`, `amount`, `current_bid`, `ends_at`, `minimum_next_bid` + +## 2. Outbid + +| Field | Value | +|-------|--------| +| Type key | `outbid` | +| Trigger | New bid accepted that exceeds previous high bid; previous high bidder ≠ new bidder | +| Recipient | Previous high bidder | +| Subject | `You've been outbid on {{artwork_title}}` | + +**Variables:** `bidder_name`, `artwork_title`, `artwork_url`, `your_amount`, `current_bid`, `minimum_next_bid`, `ends_at` + +## 3. Winner + +| Field | Value | +|-------|--------| +| Type key | `winner` | +| Trigger | Artwork closed (cron after `ends_at`, or admin close) with at least one bid | +| Recipient | Winning user (`winner_user_id`) | +| Subject | `You won: {{artwork_title}}` | + +**Variables:** `winner_name`, `artwork_title`, `artwork_url`, `winning_amount`, `pickup_or_next_steps` (static admin-configured blurb for MVP) + +## 4. Auction closed + +| Field | Value | +|-------|--------| +| Type key | `auction_closed` | +| Trigger | Artwork transitions to `closed` | +| Recipient | All admins (and optionally winner — winner already gets `winner`) | +| Subject | `Auction closed: {{artwork_title}}` | + +**Variables:** `artwork_title`, `artwork_url`, `status`, `winning_amount` (or `none`), `winner_email` (admins only), `bid_count` + +## 5. Auction ending soon + +| Field | Value | +|-------|--------| +| Type key | `auction_ending_soon` | +| Trigger | Scheduler finds `active` artwork with `ends_at` within window (default **24 hours**); send once per user/artwork | +| Recipient | MVP: current high bidder (if any). Optional: all users who bid on that artwork (nice-to-have) | +| Subject | `Ending soon: {{artwork_title}}` | + +**Variables:** `bidder_name`, `artwork_title`, `artwork_url`, `current_bid`, `minimum_next_bid`, `ends_at` + +Idempotency: unique constraint / lookup on `(type, user_id, artwork_id)` before send. + +--- + +## Shared footer variables + +All emails: `site_name` (`Hacker Dojo`), `support_email` (ops-configured), `unsubscribe_note` (transactional; short why-received line). + +## Implementation notes + +- Send inline from API after successful DB commit when possible (`bid_received`, `outbid`). +- `winner` + `auction_closed` from close job/admin action. +- `auction_ending_soon` from secured cron route. +- Failures: log + leave `sent_at` null; do not roll back the bid. +- No marketing digests in MVP. diff --git a/docs/auction/README.md b/docs/auction/README.md new file mode 100644 index 0000000..a204c02 --- /dev/null +++ b/docs/auction/README.md @@ -0,0 +1,115 @@ +# 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. + +## Goals + +- Document a minimal, maintainable silent auction MVP that fits this repository. +- Prefer reuse of existing patterns (Jekyll pages, `api/` serverless handlers, site CSS/fonts). +- Keep scope small: list → bid → countdown → notify → admin. +- Produce a reviewable plan that can be implemented in short, sequential tasks. + +## Non-goals + +- Online marketplace / multi-seller storefront +- Live / real-time bidding (WebSockets, polling fleets, Redis) +- Payment processing in MVP (Phase 2) +- Mobile apps, microservices, or new frontend frameworks +- Redesigning the Hacker Dojo marketing site +- Donor transparency dashboards (Phase 3) +- Impact Relay integration (Phase 4) + +## Feature diagram + +```text + +------------------+ + | Public Gallery | + +--------+---------+ + | + v + +------------------+ + | Artwork Page | + | + countdown | + +--------+---------+ + | + place bid (auth) + | + v + +--------------+---------------+ + | Bid API | + | validate → persist → notify | + +------+----------------+------+ + | | + v v + +-------------+ +-------------+ + | Database | | Emails | + +-------------+ +-------------+ + ^ + | + +------+------+ + | Admin pages | + | CRUD / close| + +-------------+ +``` + +## Documentation index + +| Doc | Purpose | +|-----|---------| +| [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 | +| [DATA_MODEL.md](./DATA_MODEL.md) | MVP tables: Artwork, Bid, Notification (+ User gap) | +| [API.md](./API.md) | REST endpoints, payloads, errors | +| [UI.md](./UI.md) | ASCII wireframes only | +| [EMAILS.md](./EMAILS.md) | Notification templates and triggers | +| [SECURITY.md](./SECURITY.md) | AuthZ, validation, rate limits, bid integrity | +| [ACCEPTANCE.md](./ACCEPTANCE.md) | Requirement → implementation → verification | +| [ROADMAP.md](./ROADMAP.md) | Full roadmap: Phases 0–4 | +| [TODO.md](./TODO.md) | Tasks by slice / phase (~2 hours each) | + +## Build path (Phase 1) + +See **[SLICES.md](./SLICES.md)**. Short version: + +| Slice | Delivers | +|-------|----------| +| 0 Bootstrap | DB + admin user | +| 1 Browse | Gallery, detail, countdown | +| 2 Bid | Login + place bid | +| 3 Admin | Create / edit / close | +| 4 Emails | Bid / outbid / winner / closed / ending soon | +| 5 Ship | Staging acceptance → **minimal fully functional MVP** | + +## Roadmap (summary) + +| Phase | Name | Status | +|-------|------|--------| +| 0 | Documentation | Docs package done; **T00d** decisions still open | +| 1 | MVP (slices 0–5) | Planned — [SLICES.md](./SLICES.md) + [TODO.md](./TODO.md) | +| 2 | Payments | After MVP — ROADMAP + TODO T27–T34 | +| 3 | Donor transparency | After payments — TODO T35–T41 | +| 4 | Impact Relay integration | When API exists — TODO T42–T48 | + +Details: [ROADMAP.md](./ROADMAP.md). + +## Current status + +| 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 | **Unchanged** | +| Implementation | Not started — blocked on [DECISIONS.md](./DECISIONS.md) D1–D4 | diff --git a/docs/auction/REQUIREMENTS.md b/docs/auction/REQUIREMENTS.md new file mode 100644 index 0000000..ec17df3 --- /dev/null +++ b/docs/auction/REQUIREMENTS.md @@ -0,0 +1,78 @@ +# Requirements — Silent Auction MVP + +## Functional requirements + +### F1 — Artwork listing (gallery) + +- Public page lists artworks with status `active` (and optionally `preview`). +- Each card shows: title, artist, primary image, current bid (or starting bid), time remaining. +- Closed / draft items are not shown on the public gallery (unless an explicit “past auctions” view is added later — out of MVP). + +### F2 — Artwork page + +- Public detail page for one artwork: images, title, artist, description, starting bid, current bid, minimum increment, countdown to `ends_at`, status. +- Shows recent bid amount history at a high level (amount + time; bidder display name optional/privacy-aware). +- Clear call-to-action to place a bid when status is `active` and `now < ends_at`. + +### F3 — Bid + +- Authenticated user can submit a bid amount for an active artwork. +- Server rejects bids that are below `current_bid + minimum_increment` (or below `starting_bid` when no bids exist). +- Server rejects bids after `ends_at` or when status is not `active`. +- On success: persist Bid, update Artwork `current_bid`, emit notifications (see EMAILS.md). +- No proxy bidding / auto-bid in MVP. + +### F4 — Countdown + +- Artwork page shows time remaining until `ends_at`. +- Countdown is informational; **server time / `ends_at` is authoritative** for accepting bids. +- No WebSocket live clock sync required; page load + simple client timer is enough. + +### F5 — Admin + +- Admin-authenticated users can: + - Create / update artwork fields (title, artist, description, images, starting_bid, minimum_increment, ends_at, status) + - List all artworks (all statuses) + - View bids for an artwork + - Close an artwork early (`status = closed`) or mark winner after end +- Non-admins cannot access admin endpoints or pages. + +### F6 — Email notifications + +System sends the emails defined in [EMAILS.md](./EMAILS.md): + +- Bid received (bidder) +- Outbid (previous high bidder) +- Auction ending soon (current high bidder and/or watchers — MVP: current high bidder + optional admin) +- Winner +- Auction closed (admin summary and/or winner copy as specified) + +## Nonfunctional requirements + +| ID | Requirement | +|----|-------------| +| N1 Accessibility | Forms and pages usable with keyboard; labels on inputs; sufficient contrast; images have alt text | +| N2 Maintainability | Few files; follow existing Jekyll + `api/` patterns; avoid new frameworks | +| N3 Simplicity | No WebSockets, Redis, queues (unless repo already has them — it does not), no microservices | +| N4 Responsive | Gallery, artwork, bid UI, admin usable on mobile widths | +| N5 Secure | AuthN/AuthZ on mutating routes; input validation; rate limiting; bid integrity (see SECURITY.md) | + +## Out of scope (explicit) + +- Payment capture, checkout, invoicing, escrow +- Shipping / pickup logistics workflows +- Live bidding / presence indicators +- Auto-bid / proxy bid agents +- Multi-currency +- Seller marketplace accounts +- Social sharing optimization / SEO program beyond basic page titles +- Redesign of global nav, homepage, or brand system +- Native mobile apps +- Impact Relay integration +- Public donor leaderboards / transparency portal +- Guaranteed reuse of Nexudus sessions (unknown; see CURRENT_STATE.md) + +## Requirement traceability + +Acceptance mapping lives in [ACCEPTANCE.md](./ACCEPTANCE.md). +Implementation tasks live in [TODO.md](./TODO.md). diff --git a/docs/auction/ROADMAP.md b/docs/auction/ROADMAP.md new file mode 100644 index 0000000..98b7782 --- /dev/null +++ b/docs/auction/ROADMAP.md @@ -0,0 +1,236 @@ +# Roadmap — Auction Space (Silent Auction) + +Full delivery roadmap for Hacker Dojo **Auction Space**. +Stay aligned with “simple fundraising,” not marketplace scope. + +```text +Phase 0 docs → Phase 1 MVP → Phase 2 payments → Phase 3 transparency → Phase 4 Impact Relay +``` + +| Phase | Name | Goal | +|-------|------|------| +| 0 | Documentation | Plan reviewed before code | +| 1 | MVP | List, bid, countdown, admin, emails | +| 2 | Payments | Collect / track winner payment | +| 3 | Donor transparency | Public totals + board reporting | +| 4 | Impact Relay | Feed outcomes into Dojo impact systems | + +Phase 1 build order (slices): **[SLICES.md](./SLICES.md)** +Task checklist: [TODO.md](./TODO.md) + +--- + +## Phase 0 — Documentation + +**Goal:** Design and document the MVP so reviewers can approve scope and infrastructure before implementation. + +### In scope + +- 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** + +### Out of scope + +- Application code, schema migrations, deploy config for auction runtime + +### 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) + +### 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 +- [ ] Phase 1 kickoff approved + +--- + +## Phase 1 — MVP (minimal fully functional auction) + +**Goal:** Ship the smallest auction that staff can actually run end-to-end. + +Build as six slices (details in [SLICES.md](./SLICES.md)): + +| Slice | Name | Demo after slice | +|-------|------|------------------| +| 0 | Bootstrap | Schema + admin user | +| 1 | Browse | Gallery + detail + countdown | +| 2 | Bid | Login + valid bid sticks | +| 3 | Admin | Create / edit / close without DB | +| 4 | Emails | Bid / outbid / winner / closed / ending soon | +| 5 | Ship | Staging passes acceptance → **MVP done** | + +One PR per slice when practical. Do not start slice *N+1* until slice *N* **Done when** passes. + +### In scope + +- Minimal User identity + session auth (new; none exists in-repo today) +- Artwork / Bid / Notification persistence +- Public gallery + artwork page + countdown +- Authenticated place-bid with increment / end-time integrity +- Admin create / edit / close +- Emails: bid received, outbid, ending soon, winner, auction closed + +### Out of scope + +- Payments, payouts, invoices beyond “contact winner offline” +- Live bidding / WebSockets / Redis / queues +- Site redesign, marketplace multi-seller flows +- Donor transparency UI, Impact Relay + +### Deliverables + +- Jekyll pages under `/auction/` (+ admin) +- `api/auction/*` handlers +- DB migrations for User, Artwork, Bid, Notification +- Operator runbook (create → activate → close) + +### Exit criteria + +- Slices 0–5 complete per [SLICES.md](./SLICES.md) +- [ACCEPTANCE.md](./ACCEPTANCE.md) F1–F6 pass on staging +- Nonfunctional checks N1–N5 pass +- Secrets in env (not hardcoded); CORS not open `*` on authenticated routes + +### Depends on + +- Phase 0 exit criteria (esp. infrastructure decisions) + +--- + +## Phase 2 — Payments + +**Goal:** Let winners pay (or be marked paid) without becoming a marketplace. + +### In scope + +- Winner payment collection (e.g. Stripe Checkout, payment link, or invoice URL) +- Payment state on Artwork or a small `Payment` table (`unpaid` / `pending` / `paid` / `waived`) +- Admin mark-paid / resend payment link +- Receipt email to winner; paid notice to admins +- Basic reconciliation list in admin (lot, winner, amount, status) + +### Out of scope + +- Escrow, split payouts to artists, tax forms automation +- Cart / multi-lot checkout (optional fast-follow only if needed) +- Subscriptions or membership billing +- Changing core bid rules from Phase 1 + +### Deliverables + +- Payment provider integration (reviewer-chosen) +- Admin payment controls + winner payment CTA/email +- Data model extension documented in DATA_MODEL (or addendum) +- Acceptance cases for paid / failed / waived + +### Exit criteria + +- Closed lot with winner can collect payment end-to-end on staging +- Admin can see payment status per lot +- No card data stored in this app (provider-hosted checkout) + +### Depends on + +- Phase 1 MVP stable in production or staging +- Approved payment provider + nonprofit Stripe/account setup + +--- + +## Phase 3 — Donor transparency + +**Goal:** Show fundraising impact simply and support board reporting. + +### In scope + +- Public aggregate totals raised (sum of paid winning bids for closed lots in a campaign/window) +- Optional anonymized recognition wall (display name or “Anonymous”) +- Admin export (CSV) of lots, winners, amounts, payment status for board / impact report +- Simple “campaign” or date-range filter if multiple auctions run over time + +### Out of scope + +- Full CRM / donor management suite +- Tax receipt generation (unless trivial reuse of Phase 2 receipts) +- Complex analytics product +- Public exposure of bidder emails or exact identities without consent + +### Deliverables + +- Public transparency page (Jekyll + API aggregates) +- Opt-in display name for recognition +- Export endpoint or admin download +- Privacy notes in SECURITY / UI copy + +### Exit criteria + +- Public page shows correct aggregate for paid lots only +- Export matches admin totals +- No PII leaked on public surfaces + +### Depends on + +- Phase 2 payment status (so “raised” means money received, not just bid) + +--- + +## Phase 4 — Impact Relay integration + +**Goal:** Connect auction outcomes to Impact Relay (or successor) so fundraising results feed broader Dojo impact storytelling. + +### In scope + +- Map closed/paid auction outcomes to Impact Relay events or records +- Push or pull integration using Impact Relay’s documented API (when available) +- Idempotent sync (re-runs do not double-count) +- Admin visibility: last sync time / errors +- Align fields with Phase 3 aggregates (amount raised, lot counts, campaign) + +### Out of scope + +- Replacing Impact Relay itself +- Building a second impact warehouse inside this repo +- Real-time streaming unless Impact Relay requires it (prefer simple batch/webhook) + +### Deliverables + +- Integration design note (addendum under `docs/auction/` when API is known) +- Sync job or secured API route +- Config for Impact Relay credentials / endpoint +- Acceptance: sample lot → appears correctly in Impact Relay + +### Exit criteria + +- At least one staging auction campaign syncs paid totals successfully +- Failures are logged and retriable without duplicate totals +- Docs updated with actual API contract (replace TBD) + +### Depends on + +- Impact Relay API/docs availability +- Phase 2 (and ideally Phase 3) data to sync +- Ops ownership of Impact Relay credentials + +--- + +## Cross-phase constraints (always) + +| Keep | Avoid | +|------|--------| +| Jekyll + existing site chrome | Site redesign | +| `api/` serverless style | Microservices | +| Simple fundraising UX | Marketplace / multi-seller | +| Server-authoritative bids | WebSockets / live boards | +| Docs updated when behavior changes | Silent scope creep | + +## Suggested sequencing notes + +1. Do not start Phase 2 until Phase 1 acceptance passes. +2. Phase 3 totals should prefer **paid** amounts (Phase 2) over bid amounts. +3. Phase 4 should consume stable Phase 2/3 fields — avoid one-off Impact Relay schemas in Phase 1. +4. If Impact Relay lands earlier than payments, sync **pledged** winning bids only with clear labeling; switch to paid when Phase 2 ships. diff --git a/docs/auction/SECURITY.md b/docs/auction/SECURITY.md new file mode 100644 index 0000000..378865a --- /dev/null +++ b/docs/auction/SECURITY.md @@ -0,0 +1,84 @@ +# Security — Silent Auction MVP + +## Authentication + +- **Gap:** No in-repo auth today (CURRENT_STATE.md). +- MVP introduces email magic-link / OTP → HTTP-only, `Secure`, `SameSite=Lax` (or `Strict`) session cookie. +- Session server-side store or signed cookie with rotation; expire reasonably (e.g. 14 days idle max — finalize in implementation). +- Login endpoints must be rate-limited. +- Do not embed long-lived secrets in Jekyll pages or client JS. + +## Authorization + +| Action | Who | +|--------|-----| +| List/view public active/preview artworks | Anyone | +| Place bid | Authenticated `bidder` or `admin` | +| Admin CRUD / close / view bidder emails | `role = admin` only | +| Cron ending-soon | Shared server secret, not user session | + +Enforce checks on the **server** for every mutating route. Hide admin UI as UX only — never as the only control. + +Admin allowlist: seed via env (emails) at deploy; avoid open self-signup to admin. + +## Input validation + +- Validate types/ranges for all money fields (positive, 2 decimal places, max ceiling). +- Sanitize/limit description and title lengths. +- Images: allow only `https` URLs (or uploads to a known bucket if added later); no arbitrary `javascript:` URLs. +- Reject unknown fields or strip them. +- Use parameterized queries / ORM bindings only. + +## Rate limiting + +| Surface | Suggested MVP limit | +|---------|---------------------| +| Auth request-link | Low per email + per IP (e.g. 5 / hour / email) | +| Place bid | Moderate per user + per artwork (e.g. 30 / hour) | +| Public GETs | Standard edge/platform limits | + +Return `429` + `RATE_LIMITED`. Platform edge rate limits are acceptable if documented. + +## Spam prevention + +- Magic-link/OTP instead of passwords reduces credential stuffing surface. +- Generic responses on auth request to reduce account enumeration where practical. +- Honeypot or Turnstile/CAPTCHA on auth request **if** abuse appears (not required day one). +- Do not open CORS to `*` on authenticated auction APIs (waitlist currently uses `*`; **do not copy that** for bids). + +## Bid integrity + +- Server clock / DB `now()` authoritative vs client countdown. +- Transactional update: lock artwork row → re-read `current_bid` / `status` / `ends_at` → validate → insert bid → update `current_bid`. +- Never trust client `minimum_next_bid`. +- Disallow bidder from “editing” past bids; append-only Bid table. +- Admin changes to increments/`ends_at` must not invalidate history; document rules in API.md. + +## Audit logging + +MVP minimum (can be DB rows or structured logs): + +| Event | Fields | +|-------|--------| +| bid_accepted | artwork_id, bid_id, user_id, amount, ip/user-agent hash | +| bid_rejected | reason code, user_id, artwork_id, amount | +| admin_artwork_mutation | admin_id, artwork_id, patch summary | +| admin_close | admin_id, artwork_id, winner_user_id | +| auth_login | user_id, method | +| email_sent / email_failed | notification_id, type | + +Retain enough to resolve “who bid what when” disputes. Do not log raw magic tokens. + +## Secrets & hosting + +- Move any webhook/API keys to environment variables (waitlist currently hardcodes an Airtable webhook URL — **do not repeat** for auction). +- Confirm serverless host auth to env vars before implementation PR merges. + +## Threat notes (lightweight) + +| Threat | Mitigation | +|--------|------------| +| Last-second sniping | Accept until `ends_at`; optional short anti-snipe extension is **out of MVP** | +| Bid scraping | Public amounts OK; hide emails | +| Admin XSS via description | Escape on render; strict CSP if feasible later | +| Session theft | HTTPS, HttpOnly cookie, short OTP TTL | diff --git a/docs/auction/SLICES.md b/docs/auction/SLICES.md new file mode 100644 index 0000000..5e00dfe --- /dev/null +++ b/docs/auction/SLICES.md @@ -0,0 +1,168 @@ +# Implementation slices — Auction Space (minimal fully functional) + +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 + ↓ +Slice 1 Browse (gallery + detail + countdown) + ↓ +Slice 2 Bid (login + place bid) + ↓ +Slice 3 Admin (create / edit / close) + ↓ +Slice 4 Emails (notify bidders + admins) + ↓ +Slice 5 Ship (hardening + staging acceptance) + ↓ + ★ MINIMAL FULLY FUNCTIONAL AUCTION ★ + ↓ +Phase 2+ Payments → transparency → Impact Relay +``` + +## What “fully functional” means (MVP) + +After **Slices 0–5**, staff can run a real fundraiser: + +1. Admin creates an artwork lot and sets an end time +2. Public browses gallery and lot page with countdown +3. Bidder logs in and places a valid bid +4. Outbid / bid-received emails send +5. Admin closes (or end time passes + close); winner + closed emails send + +**Not required for MVP:** payments, live sockets, donor wall, Impact Relay. + +Tasks: [TODO.md](./TODO.md). Acceptance: [ACCEPTANCE.md](./ACCEPTANCE.md). + +--- + +## Slice 0 — Bootstrap + +**User value:** Team can develop against a real schema and one admin account. + +| Include | Skip until later | +|---------|------------------| +| Record DB / auth / email / host choices | Bid UI | +| Migrations: User, Artwork, Bid, Notification | Public pages | +| Seed one admin email | Email templates | +| Shared API helpers (JSON, errors, money, auth guards) | | + +**Done when:** Migrations apply on staging/dev; admin user exists; a health or trivial authenticated admin ping works. + +**Tasks:** T01–T05 + +--- + +## Slice 1 — Browse + +**User value:** Anyone can see active lots and a countdown. + +| Include | Skip until later | +|---------|------------------| +| `GET` artworks list + detail (+ bid history amounts) | Place bid | +| Seed **one** `active` artwork (SQL/admin script OK) | Login | +| Gallery page `/auction/` | Admin UI | +| Artwork page + client countdown from `ends_at` | Emails | + +**Done when:** Opening `/auction/` shows the seeded lot; detail shows current/starting bid and a ticking countdown; refresh after `ends_at` still shows the lot as ended (bidding still disabled until Slice 2). + +**Tasks:** T09–T11 (+ seed helper if needed) + +--- + +## Slice 2 — Bid + +**User value:** A logged-in person can raise the high bid safely. + +| Include | Skip until later | +|---------|------------------| +| Magic-link / OTP login + session | Admin CRUD UI | +| `POST /bids` with row lock + validation | Payment | +| Bid modal on artwork page | Ending-soon cron | +| Reject too-low / after `ends_at` / inactive | Fancy history UI | + +**Done when:** User requests login → verifies → submits minimum next bid → page shows new current bid; a second lower bid fails; bid after end fails. + +**Tasks:** T06–T08, T12–T14 + +--- + +## Slice 3 — Admin + +**User value:** Staff run the auction without touching the database. + +| Include | Skip until later | +|---------|------------------| +| Admin list / create / patch / delete-draft / close APIs | Transparency export | +| Admin HTML page (table + form + bid list) | Payment status | +| Close sets winner from high bid | | + +**Done when:** Admin creates a lot, activates it, sees bids, closes it; public gallery updates accordingly. Seed script no longer required for a second lot. + +**Tasks:** T19–T21 + +--- + +## Slice 4 — Emails + +**User value:** Bidders and admins learn about bid / win / close without watching the site. + +| Include | Skip until later | +|---------|------------------| +| Email helper + `Notification` rows | Marketing digests | +| `bid_received` + `outbid` on successful bid | | +| `winner` + `auction_closed` on close | | +| `auction_ending_soon` via secured cron (once per user/lot) | | + +**Done when:** Placing a bid sends received + outbid; closing sends winner + closed; ending-soon fires once inside the window ([EMAILS.md](./EMAILS.md)). + +**Tasks:** T15–T18 + +--- + +## Slice 5 — Ship + +**User value:** Safe enough to run a real event on staging/production. + +| Include | Skip until later | +|---------|------------------| +| CORS lockdown + CSRF/origin on cookie POSTs | Phase 2 payments | +| Rate limits + basic audit logs | | +| Accessibility pass on gallery / detail / modal / admin | | +| Staging deploy config + F1–F6 smoke | | +| One-page operator runbook | | + +**Done when:** [ACCEPTANCE.md](./ACCEPTANCE.md) F1–F6 and N1–N5 pass on staging. +→ **Minimal fully functional auction complete.** + +**Tasks:** T22–T26 + +--- + +## Slice demo script (end-to-end) + +Use after Slice 5 (or after Slice 4 for a soft demo): + +1. Admin logs in → creates “Test Lot” → status `active`, end time +1 day +2. Incognito: gallery shows Test Lot + countdown +3. Bidder A logs in → bids starting amount → gets bid-received email +4. Bidder B logs in → bids minimum next → A gets outbid email +5. Admin closes lot (or wait for end + close job) → B gets winner email; admins get closed email +6. Public: lot no longer accepts bids + +--- + +## Later phases (not MVP) + +Keep these **after** Slices 0–5. Do not pull them into the minimal cut. + +| Phase | One-line slice goal | +|-------|---------------------| +| 2 Payments | Winner can pay; admin sees paid/unpaid | +| 3 Transparency | Public total raised (paid) + CSV export | +| 4 Impact Relay | Paid outcomes sync without double-count | + +See [ROADMAP.md](./ROADMAP.md) and [TODO.md](./TODO.md) Phases 2–4. diff --git a/docs/auction/START_HERE.md b/docs/auction/START_HERE.md new file mode 100644 index 0000000..0c8b8ef --- /dev/null +++ b/docs/auction/START_HERE.md @@ -0,0 +1,48 @@ +# Start Here + +## Reading order + +1. [README.md](./README.md) — overview and index +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. +4. Later: Phase 2 payments → Phase 3 transparency → Phase 4 Impact Relay. + +```text +Browse → Bid → Admin → Emails → Ship = usable fundraiser +``` + +## Assumptions + +- Reviewers accept that MVP introduces a small backend surface (DB + auth + email) because none exist in this repository today. See CURRENT_STATE.md. +- Auction UI is added as Jekyll pages under the existing site, reusing `_layouts/default.html`, header/footer, and existing CSS/fonts where practical. +- Bid API handlers follow the existing `api/*.js` serverless style used by `api/waitlist.js`. +- No WebSockets, Redis, message queues, or microservices. +- Countdown and “current bid” refresh via normal page load / lightweight client fetch — not live sockets. +- Payments are out of scope for MVP (settle offline or Phase 2). + +## Scope + +**Minimal fully functional (Slices 0–5):** artwork listing, artwork detail, authenticated bid, countdown, admin management, core emails. + +**Later:** payments, donor transparency, Impact Relay. + +**Out of scope always (for now):** marketplace features, live bidding, site redesign. + +Keep every PR about the size of one slice (or one task inside a slice). diff --git a/docs/auction/TODO.md b/docs/auction/TODO.md new file mode 100644 index 0000000..a23c062 --- /dev/null +++ b/docs/auction/TODO.md @@ -0,0 +1,130 @@ +# TODO — Implementation tasks (~2 hours each) + +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. + +--- + +## Phase 0 — Documentation + +- [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) +- [ ] **T00d** Reviewer records DB / auth / email / API host decisions in DECISIONS.md +- [x] **T00e** Upstream Phase 0 docs merged; open Phase 1 PRs **one slice at a time** after T00d + +--- + +## Phase 1 — MVP (by slice) + +Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.md](./SLICES.md) before the next starts. + +### Slice 0 — Bootstrap + +- [ ] **T01** Copy approved choices from [DECISIONS.md](./DECISIONS.md) into Slice 0 PR; confirm env vars present in staging. +- [ ] **T02** Add DB client + migration tooling; empty migration pipeline runs. +- [ ] **T03** Migrate `User` + seed one admin email from env. +- [ ] **T04** Migrate `Artwork`, `Bid`, `Notification` + indexes ([DATA_MODEL.md](./DATA_MODEL.md)). +- [ ] **T05** Shared API helpers: JSON, error codes, money parse/validate, requireUser / requireAdmin. + +**Slice 0 done when:** migrations apply; admin user exists. + +### Slice 1 — Browse + +- [ ] **T09** `GET /api/auction/artworks` + `GET /api/auction/artworks/:id` (+ bid amounts). +- [ ] **T09b** Seed one `active` artwork for local/staging demos. +- [ ] **T10** Jekyll gallery page `/auction/` wired to list API. +- [ ] **T11** Artwork detail page + countdown from `ends_at` (bid CTA disabled or “coming next”). + +**Slice 1 done when:** visitor can browse lots and see a live countdown. + +### Slice 2 — Bid + +- [ ] **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). +- [ ] **T12** `POST /api/auction/bids` with transactional row lock + validation errors. +- [ ] **T13** Bid modal UI + success/error + refresh current bid on page. +- [ ] **T14** Manual concurrency check (two near-simultaneous bids). + +**Slice 2 done when:** logged-in user can place a valid bid; invalid/late bids fail cleanly. + +### Slice 3 — Admin + +- [ ] **T19** Admin list/create API (`GET`/`POST` artworks). +- [ ] **T20** Admin patch + delete-draft + close (sets winner). +- [ ] **T21** Admin HTML page: table, editor form, bid list. + +**Slice 3 done when:** staff can create → activate → close a lot without DB access. + +### Slice 4 — Emails + +- [ ] **T15** Email send helper + `Notification` write on success/failure. +- [ ] **T16** `bid_received` + `outbid` from bid handler. +- [ ] **T17** `winner` + `auction_closed` on close. +- [ ] **T18** Secured cron for `auction_ending_soon` + idempotency. + +**Slice 4 done when:** emails in [EMAILS.md](./EMAILS.md) send for the happy path. + +### Slice 5 — Ship → minimal fully functional + +- [ ] **T22** CORS lockdown for auction APIs; CSRF/origin checks for cookie POSTs. +- [ ] **T23** Rate limits on auth + bid; audit log lines for bid/admin events. +- [ ] **T24** Accessibility pass on gallery, detail, modal, admin forms. +- [ ] **T25** Staging deploy config checked in; smoke [ACCEPTANCE.md](./ACCEPTANCE.md) F1–F6. +- [ ] **T26** Operator runbook: create lot → activate → close; email resend policy. + +**Slice 5 done when:** staging passes F1–F6 + N1–N5. +**★ Minimal fully functional auction is complete. Stop here for first production fundraiser.** + +--- + +## Phase 2 — Payments (after MVP) + +Depends on Slices 0–5 + approved payment provider. + +- [ ] **T27** Choose provider + document env vars / webhook secrets. +- [ ] **T28** Add `Payment` table or Artwork payment columns (`unpaid` / `pending` / `paid` / `waived`). +- [ ] **T29** Create checkout / payment-link API for a closed winning lot. +- [ ] **T30** Webhook (or confirm endpoint) marks paid; idempotent. +- [ ] **T31** Admin: view status, mark waived, resend payment link. +- [ ] **T32** Winner pay-link + receipt emails; admin paid notice. +- [ ] **T33** Acceptance: unpaid → pay → paid; webhook retry; waived. +- [ ] **T34** Update DATA_MODEL / API / EMAILS / ACCEPTANCE for payments. + +--- + +## Phase 3 — Donor transparency (after payments) + +- [ ] **T35** Aggregate API: totals raised (paid only) for campaign / date range. +- [ ] **T36** Public transparency page (aggregate + optional lot count). +- [ ] **T37** Opt-in recognition display name; Anonymous default. +- [ ] **T38** Optional public recognition wall (no emails). +- [ ] **T39** Admin CSV export: lots, winners, amounts, payment status. +- [ ] **T40** Privacy copy; verify no PII on public page. +- [ ] **T41** Acceptance: aggregates match export; anonymous honored. + +--- + +## Phase 4 — Impact Relay (when API exists) + +- [ ] **T42** Integration addendum when Impact Relay API/docs exist. +- [ ] **T43** Map paid/closed fields to Impact Relay schema. +- [ ] **T44** Secured sync route or scheduled job + last sync cursor. +- [ ] **T45** Idempotent upsert (no double-count). +- [ ] **T46** Admin: last sync, errors, manual re-sync. +- [ ] **T47** Staging proof: sample campaign appears in Impact Relay. +- [ ] **T48** Update ROADMAP / runbook with real endpoints. + +--- + +## Explicitly out of roadmap + +- WebSockets / live bidding +- Redis / message queues (unless repo gains them for other reasons) +- Microservices / SPA rewrite +- Marketplace multi-seller accounts +- Nexudus SSO (until CURRENT_STATE UNKNOWN is resolved) diff --git a/docs/auction/UI.md b/docs/auction/UI.md new file mode 100644 index 0000000..b52d585 --- /dev/null +++ b/docs/auction/UI.md @@ -0,0 +1,119 @@ +# UI — Wireframes (ASCII only) + +No mockups. No visual redesign of the global site. Auction pages use the existing default layout (header / footer). + +## 1. Auction gallery — `/auction/` + +```text ++--------------------------------------------------------------+ +| [Hacker Dojo header — existing] | ++--------------------------------------------------------------+ +| Silent Auction | +| Fundraising lots · bid before the countdown ends | +| | +| +------------------+ +------------------+ +--------------+ | +| | [image] | | [image] | | [image] | | +| | Title | | Title | | Title | | +| | Artist | | Artist | | Artist | | +| | Current $XX | | Current $XX | | Starting $XX | | +| | Ends in 2d 4h | | Ends in 5h | | Ends in 1d | | +| | [View] | | [View] | | [View] | | +| +------------------+ +------------------+ +--------------+ | +| | ++--------------------------------------------------------------+ +| [Hacker Dojo footer — existing] | ++--------------------------------------------------------------+ +``` + +Mobile: single column stack of lots. + +## 2. Artwork page — `/auction/artwork/:id/` + +```text ++--------------------------------------------------------------+ +| [header] | ++--------------------------------------------------------------+ +| +---------------------+ Title | +| | | Artist | +| | primary image | Current bid: $30.00 | +| | | Min next: $35.00 | +| +---------------------+ Ends in: 1d 03:12:45 | +| [thumbs...] Status: Active | +| | +| Description | +| ............................................................ | +| | +| Recent bids | +| $30.00 · 2h ago · Jamie | +| $25.00 · 1d ago · Sam | +| | +| [ Place bid ] (disabled if closed / not logged in → prompt)| ++--------------------------------------------------------------+ +| [footer] | ++--------------------------------------------------------------+ +``` + +## 3. Bid modal + +Opened from Place bid. Keep modal minimal — form only. + +```text ++--------------------------------------+ +| Place a bid [X]| +|--------------------------------------| +| Torii at Dusk | +| Minimum bid: $35.00 | +| | +| Amount | +| [ 35.00 ] | +| | +| [ Cancel ] [ Submit bid ] | +| | +| Note: You will get an email if | +| someone outbids you. | ++--------------------------------------+ +``` + +States: + +- Logged out → replace body with “Log in with email” (magic link / OTP) +- Error → inline message (`BID_TOO_LOW`, etc.) +- Success → short confirmation + updated current bid on page + +## 4. Admin page — `/auction/admin/` + +Protected. No cards-for-decoration; simple table + form. + +```text ++--------------------------------------------------------------+ +| Admin · Silent Auction | +| [ New artwork ] | +| | +| Title Artist Status Current Ends Ops | +| -------------- ---------- -------- --------- ----------- --- | +| Torii at Dusk A. Maker active 30.00 Sep 1 … Edit| +| Sketch #12 B. Friend draft — Sep 5 … Edit| +| | ++--------------------------------------------------------------+ +| Edit artwork | +| Title [..................] Artist [................] | +| Description [........................................] | +| Images (URLs, one per line) | +| [........................................................] | +| Starting [20.00] Increment [5.00] Ends at [datetime-local] | +| Status [ draft v ] | +| [ Save ] [ Close auction ] [ Delete (draft only) ] | +| | +| Bids for this lot | +| $30.00 Jamie email@… 2026-08-20 18:01 | +| $25.00 Sam email@… 2026-08-19 09:12 | ++--------------------------------------------------------------+ +``` + +## UI rules for implementers + +- Reuse existing header/footer; do not invent a new marketing chrome. +- One job per page: gallery browse · lot detail/bid · admin manage. +- Countdown is visible on gallery cards and detail; server enforces end time. +- Prefer existing CSS variables/colors from `static/css/style.css`; Tailwind CDN only if it meaningfully speeds forms (as on waitlist) without becoming a new design system. +- No live-updating ticker beyond a local countdown timer + refresh after bid. From e49f63bd4db094d0ef774ad2de407005d1824879 Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 4 Aug 2026 13:55:59 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(auction):=20Slice=200=E2=80=931=20boot?= =?UTF-8?q?strap=20and=20public=20browse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record infrastructure defaults (Postgres, magic-link auth, Resend, Vercel) and ship the first runnable Auction Space foundation on the existing site. Slice 0: migrations, admin seed, shared API helpers, health/admin ping. Slice 1: public artworks APIs, demo lot seed, /auction gallery and detail with client countdown (bid CTA deferred to Slice 2). --- .env.example | 21 ++ .gitignore | 5 + README.md | 27 ++- _includes/header.html | 2 + api/auction/admin/ping.js | 36 ++++ api/auction/artworks.js | 36 ++++ api/auction/artworks/[id].js | 30 +++ api/auction/health.js | 56 ++++++ auction/artwork.html | 17 ++ auction/index.html | 25 +++ db/migrations/001_auction_init.sql | 119 +++++++++++ docs/auction/DATA_MODEL.md | 13 +- docs/auction/DECISIONS.md | 24 ++- docs/auction/README.md | 6 +- docs/auction/SLICE_0_RUNBOOK.md | 70 +++++++ docs/auction/SLICE_1_RUNBOOK.md | 45 +++++ docs/auction/TODO.md | 22 +- lib/auction/artworks.js | 178 +++++++++++++++++ lib/auction/auth.js | 167 ++++++++++++++++ lib/auction/config.js | 70 +++++++ lib/auction/db.js | 66 ++++++ lib/auction/errors.js | 59 ++++++ lib/auction/http.js | 168 ++++++++++++++++ lib/auction/index.js | 12 ++ lib/auction/money.js | 84 ++++++++ lib/auction/users.js | 55 +++++ package-lock.json | 164 +++++++++++++++ package.json | 19 ++ scripts/auction-migrate.js | 117 +++++++++++ scripts/auction-seed-admin.js | 72 +++++++ scripts/auction-seed-demo-lot.js | 104 ++++++++++ static/css/auction.css | 309 +++++++++++++++++++++++++++++ static/js/auction.js | 307 ++++++++++++++++++++++++++++ vercel.json | 16 ++ 34 files changed, 2494 insertions(+), 27 deletions(-) create mode 100644 .env.example create mode 100644 api/auction/admin/ping.js create mode 100644 api/auction/artworks.js create mode 100644 api/auction/artworks/[id].js create mode 100644 api/auction/health.js create mode 100644 auction/artwork.html create mode 100644 auction/index.html create mode 100644 db/migrations/001_auction_init.sql create mode 100644 docs/auction/SLICE_0_RUNBOOK.md create mode 100644 docs/auction/SLICE_1_RUNBOOK.md create mode 100644 lib/auction/artworks.js create mode 100644 lib/auction/auth.js create mode 100644 lib/auction/config.js create mode 100644 lib/auction/db.js create mode 100644 lib/auction/errors.js create mode 100644 lib/auction/http.js create mode 100644 lib/auction/index.js create mode 100644 lib/auction/money.js create mode 100644 lib/auction/users.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/auction-migrate.js create mode 100644 scripts/auction-seed-admin.js create mode 100644 scripts/auction-seed-demo-lot.js create mode 100644 static/css/auction.css create mode 100644 static/js/auction.js create mode 100644 vercel.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92b6e09 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# 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 (Slice 4 emails; optional until then) +RESEND_API_KEY= +EMAIL_FROM="Hacker Dojo Auction " + +# Slice 4/5 +AUCTION_CRON_SECRET= +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/README.md b/README.md index 6cdb5fb..d531bcf 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,28 @@ Once all pre-requisites are installed, you can preview the website using: jekyll serve ``` -## Auction Space (Silent Auction) — Planning +## Auction Space (Silent Auction) -Planning docs for the fundraising silent auction live in [docs/auction](./docs/auction). -Start at [docs/auction/START_HERE.md](./docs/auction/START_HERE.md). Infrastructure choices: [docs/auction/DECISIONS.md](./docs/auction/DECISIONS.md). +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) | + +### 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=` diff --git a/_includes/header.html b/_includes/header.html index 023cc15..0da33a0 100644 --- a/_includes/header.html +++ b/_includes/header.html @@ -48,6 +48,7 @@ Events Startups + Auction Pricing Impact Report Donate @@ -85,6 +86,7 @@ Wiki Startups + Auction Pricing Impact Report Donate 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..a33ca1d --- /dev/null +++ b/api/auction/artworks.js @@ -0,0 +1,36 @@ +/** + * GET /api/auction/artworks + * Public list of auction lots. + * + * Query: status=active|preview|closed|all_public (default active) + * limit, offset + */ + +import { listPublicArtworks } from '../../lib/auction/artworks.js'; +import { json, withHandler } from '../../lib/auction/http.js'; +import { apiError, ErrorCodes } from '../../lib/auction/errors.js'; + +export default withHandler(async function artworksList(req, res) { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, OPTIONS'); + return res.end(); + } + + 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 }); +}, { methods: ['GET', 'OPTIONS'] }); diff --git a/api/auction/artworks/[id].js b/api/auction/artworks/[id].js new file mode 100644 index 0000000..0c27954 --- /dev/null +++ b/api/auction/artworks/[id].js @@ -0,0 +1,30 @@ +/** + * GET /api/auction/artworks/:id + * Public artwork detail + recent bids. + */ + +import { getPublicArtworkDetail } from '../../../lib/auction/artworks.js'; +import { json, withHandler } from '../../../lib/auction/http.js'; + +export default withHandler(async function artworkDetail(req, res) { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Allow', 'GET, OPTIONS'); + return res.end(); + } + + const id = + req.query?.id || + (req.url && req.url.match(/\/artworks\/([^/?#]+)/)?.[1]) || + null; + + if (!id) { + res.statusCode = 400; + return json(res, 400, { + error: { code: 'VALIDATION_ERROR', message: 'Missing artwork id' }, + }); + } + + const payload = await getPublicArtworkDetail(decodeURIComponent(String(id))); + return json(res, 200, payload); +}, { methods: ['GET', '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/auction/artwork.html b/auction/artwork.html new file mode 100644 index 0000000..9842b95 --- /dev/null +++ b/auction/artwork.html @@ -0,0 +1,17 @@ +--- +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..64743b2 --- /dev/null +++ b/auction/index.html @@ -0,0 +1,25 @@ +--- +layout: default +title: Silent Auction | Hacker Dojo +permalink: /auction/ +--- + + +
+
Silent Auction
+

Auction Space

+

+ Browse fundraising lots and place your bid before the countdown ends. + Proceeds support Hacker Dojo programs and community. +

+ + +
+ + + 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/DATA_MODEL.md b/docs/auction/DATA_MODEL.md index 359c1d9..0da5066 100644 --- a/docs/auction/DATA_MODEL.md +++ b/docs/auction/DATA_MODEL.md @@ -21,7 +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. -## Tables +## 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` | ### User (**NEW** — required) diff --git a/docs/auction/DECISIONS.md b/docs/auction/DECISIONS.md index 8110664..06e77fb 100644 --- a/docs/auction/DECISIONS.md +++ b/docs/auction/DECISIONS.md @@ -11,10 +11,10 @@ Related: [CURRENT_STATE.md](./CURRENT_STATE.md) unknowns · [ARCHITECTURE.md](./ | ID | Topic | Choice | Decided by | Date | Notes | |----|-------|--------|------------|------|-------| -| D1 | Database | _pending_ | | | Must support transactional row locks for bids | -| D2 | Auth method | _pending_ | | | Default proposal: email magic-link / OTP + HTTP-only session cookie | -| D3 | Email provider | _pending_ | | | Transactional only (bid / outbid / winner / closed / ending soon) | -| D4 | API host | _pending_ | | | Must run `api/*.js` serverless handlers beside waitlist | +| 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) @@ -35,15 +35,16 @@ Copy into the Slice 0 / staging secrets store. Names are suggestions — rename | Variable | Purpose | Required from | |----------|---------|---------------| -| `DATABASE_URL` | DB connection string | D1 | -| `SESSION_SECRET` | Sign/encrypt session cookies | D2 | +| `DATABASE_URL` | Postgres connection string | D1 | +| `SESSION_SECRET` | Sign/encrypt session cookies (≥32 chars) | D2 | | `ADMIN_EMAIL` | Seed admin user | Slice 0 | -| `EMAIL_API_KEY` | Provider API key | D3 | -| `EMAIL_FROM` | From address for auction mail | D3 | +| `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) | Slice 5 | +| `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 (e.g. `RESEND_API_KEY`) when D3 is chosen; keep secrets out of the client and out of git. +Add provider-specific vars when D3 is chosen; keep secrets out of the client and out of git. --- @@ -55,6 +56,8 @@ Add provider-specific vars (e.g. `RESEND_API_KEY`) when D3 is chosen; keep secre 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 @@ -63,3 +66,4 @@ Add provider-specific vars (e.g. `RESEND_API_KEY`) when D3 is chosen; keep secre |------|-------| | 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 a204c02..dcb5518 100644 --- a/docs/auction/README.md +++ b/docs/auction/README.md @@ -111,5 +111,7 @@ Details: [ROADMAP.md](./ROADMAP.md). | 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 | **Unchanged** | -| Implementation | Not started — blocked on [DECISIONS.md](./DECISIONS.md) D1–D4 | +| Application code | **Slices 0–1** — bootstrap + public browse (gallery, detail, countdown) | +| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 2 (Bid) | +| Slice 0 runbook | [SLICE_0_RUNBOOK.md](./SLICE_0_RUNBOOK.md) | +| Slice 1 runbook | [SLICE_1_RUNBOOK.md](./SLICE_1_RUNBOOK.md) | 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/TODO.md b/docs/auction/TODO.md index a23c062..f283747 100644 --- a/docs/auction/TODO.md +++ b/docs/auction/TODO.md @@ -13,7 +13,7 @@ Do not start Phase 1 coding until [DECISIONS.md](./DECISIONS.md) records DB / au - [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) -- [ ] **T00d** Reviewer records DB / auth / email / API host decisions in 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 --- @@ -24,20 +24,20 @@ Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.m ### Slice 0 — Bootstrap -- [ ] **T01** Copy approved choices from [DECISIONS.md](./DECISIONS.md) into Slice 0 PR; confirm env vars present in staging. -- [ ] **T02** Add DB client + migration tooling; empty migration pipeline runs. -- [ ] **T03** Migrate `User` + seed one admin email from env. -- [ ] **T04** Migrate `Artwork`, `Bid`, `Notification` + indexes ([DATA_MODEL.md](./DATA_MODEL.md)). -- [ ] **T05** Shared API helpers: JSON, error codes, money parse/validate, requireUser / requireAdmin. +- [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. +**Slice 0 done when:** migrations apply; admin user exists. → see [SLICE_0_RUNBOOK.md](./SLICE_0_RUNBOOK.md) ### Slice 1 — Browse -- [ ] **T09** `GET /api/auction/artworks` + `GET /api/auction/artworks/:id` (+ bid amounts). -- [ ] **T09b** Seed one `active` artwork for local/staging demos. -- [ ] **T10** Jekyll gallery page `/auction/` wired to list API. -- [ ] **T11** Artwork detail page + countdown from `ends_at` (bid CTA disabled or “coming next”). +- [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”). **Slice 1 done when:** visitor can browse lots and see a live countdown. 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/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/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/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..0124860 --- /dev/null +++ b/lib/auction/index.js @@ -0,0 +1,12 @@ +/** + * Auction Space shared library — Slice 0 surface. + */ + +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'; 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..d739a1d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "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" + }, + "dependencies": { + "pg": "^8.16.3" + } +} 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..9a1123d --- /dev/null +++ b/static/css/auction.css @@ -0,0 +1,309 @@ +/* Auction Space — silent auction pages (extends site palette) */ + +.auction-page { + max-width: 1100px; + margin: 0 auto; + padding: 32px 20px 64px; + color: #141414; +} + +.auction-page h1 { + font-family: 'Rajdhani', sans-serif; + font-size: 2.25rem; + font-weight: 700; + letter-spacing: 0.02em; + margin-bottom: 8px; + color: #0c111d; +} + +.auction-lede { + color: #444c5a; + font-size: 1.05rem; + margin-bottom: 28px; + max-width: 40rem; + line-height: 1.5; +} + +.auction-status-banner { + display: inline-block; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 6px 12px; + border-radius: 999px; + background: #fef2f2; + color: #e13838; + border: 1px solid #fecaca; + margin-bottom: 16px; +} + +.auction-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 20px; +} + +.auction-card { + background: #fff; + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(12, 17, 29, 0.08); + border: 1px solid #e8e8ec; + display: flex; + flex-direction: column; + transition: box-shadow 0.15s ease, transform 0.15s ease; +} + +.auction-card:hover { + box-shadow: 0 8px 24px rgba(12, 17, 29, 0.1); + transform: translateY(-2px); +} + +.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: #eef0f3; + object-fit: cover; + width: 100%; + display: block; +} + +.auction-card-image-placeholder { + aspect-ratio: 4 / 3; + background: linear-gradient(135deg, #eef0f3, #dfe3ea); + display: flex; + align-items: center; + justify-content: center; + color: #6b7280; + font-size: 0.85rem; +} + +.auction-card-body { + padding: 14px 16px 16px; + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.auction-card-title { + font-family: 'Rajdhani', sans-serif; + font-size: 1.2rem; + font-weight: 700; + line-height: 1.2; +} + +.auction-card-artist { + color: #5a6270; + font-size: 0.9rem; +} + +.auction-card-meta { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.9rem; +} + +.auction-card-meta strong { + font-weight: 600; + color: #0c111d; +} + +.auction-countdown { + font-variant-numeric: tabular-nums; + color: #e13838; + font-weight: 600; +} + +.auction-countdown.is-ended { + color: #6b7280; +} + +.auction-empty, +.auction-error, +.auction-loading { + padding: 32px 16px; + text-align: center; + color: #5a6270; + background: #fff; + border-radius: 12px; + border: 1px dashed #d1d5db; +} + +.auction-error { + color: #b91c1c; + border-color: #fecaca; + background: #fef2f2; +} + +/* Detail */ + +.auction-detail { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + gap: 28px; + align-items: start; +} + +@media (max-width: 800px) { + .auction-detail { + grid-template-columns: 1fr; + } +} + +.auction-detail-image-wrap { + background: #fff; + border-radius: 14px; + overflow: hidden; + border: 1px solid #e8e8ec; +} + +.auction-detail-image { + width: 100%; + display: block; + aspect-ratio: 4 / 3; + object-fit: cover; + background: #eef0f3; +} + +.auction-detail-info h1 { + margin-bottom: 4px; +} + +.auction-detail-artist { + color: #5a6270; + font-size: 1.05rem; + margin-bottom: 16px; +} + +.auction-price-block { + background: #fff; + border: 1px solid #e8e8ec; + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} + +.auction-price-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + font-size: 0.95rem; +} + +.auction-price-row .label { + color: #5a6270; +} + +.auction-price-row .value { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.auction-price-row .value.accent { + color: #e13838; + font-size: 1.15rem; +} + +.auction-status-pill { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + background: #ecfdf5; + color: #018669; + border: 1px solid #a7f3d0; + margin-bottom: 12px; +} + +.auction-status-pill.is-closed, +.auction-status-pill.is-preview { + background: #f3f4f6; + color: #4b5563; + border-color: #e5e7eb; +} + +.auction-description { + margin: 16px 0 24px; + line-height: 1.55; + color: #2d3340; + white-space: pre-wrap; +} + +.auction-bid-cta { + display: inline-block; + margin-top: 8px; + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; +} + +.auction-bid-cta.is-ready { + opacity: 1; + cursor: pointer; + pointer-events: auto; +} + +.auction-bids h2 { + font-family: 'Rajdhani', sans-serif; + font-size: 1.25rem; + margin-bottom: 10px; +} + +.auction-bids-list { + list-style: none; + background: #fff; + border: 1px solid #e8e8ec; + border-radius: 12px; + overflow: hidden; +} + +.auction-bids-list li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #f0f1f3; + font-size: 0.9rem; +} + +.auction-bids-list li:last-child { + border-bottom: none; +} + +.auction-bids-empty { + color: #6b7280; + font-size: 0.9rem; + padding: 8px 0; +} + +.auction-back { + display: inline-block; + margin-bottom: 16px; + color: #444c5a; + font-size: 0.9rem; +} + +.auction-back:hover { + color: #e13838; +} + +.auction-note { + margin-top: 12px; + font-size: 0.85rem; + color: #6b7280; +} diff --git a/static/js/auction.js b/static/js/auction.js new file mode 100644 index 0000000..5a039de --- /dev/null +++ b/static/js/auction.js @@ -0,0 +1,307 @@ +/** + * Auction Space client helpers — gallery, detail, countdown. + * Configure API base with: window.HD_AUCTION_API = 'https://your-api.vercel.app' + * (empty = same origin) + */ +(function () { + 'use strict'; + + 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) { + const res = await fetch(apiUrl(path), { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + const 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.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, '"'); + } + + 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 { + // Show active + preview + recently closed for a fuller gallery + 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) { + el.innerHTML = '
Loading artwork…
'; + try { + var data = await fetchJson( + '/api/auction/artworks/' + encodeURIComponent(id) + ); + var art = data.artwork; + 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 when bidding opens.

' + : '
    ' + + 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 ctaClass = 'button button-red auction-bid-cta' + (canBid ? '' : ''); + // Slice 1: CTA visible but not interactive (Slice 2 enables bidding) + var cta = + '' + + '

Login and bidding land in the next auction slice. Countdown and prices refresh on reload.

'; + + el.innerHTML = + '← All lots' + + '
' + + '
' + + 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 || '') + + '
' + + cta + + '

Recent bids

' + + bidRows + + '
' + + '
'; + + bindCountdowns(el); + } catch (err) { + el.innerHTML = + '← All lots' + + '
' + + escapeHtml(err.message || 'Artwork not found') + + '
'; + } + } + + 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, + }; +})(); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..771856d --- /dev/null +++ b/vercel.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "version": 2, + "functions": { + "api/**/*.js": { + "memory": 256, + "maxDuration": 10 + } + }, + "rewrites": [ + { + "source": "/api/(.*)", + "destination": "/api/$1" + } + ] +} From f6230c38c3c0c189cfec479c1a5d512ffef69bf4 Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 4 Aug 2026 14:01:27 -0700 Subject: [PATCH 3/8] feat(auction): Slice 2 OTP login and place bid Add magic-link/OTP auth (request-link, verify, session cookie), transactional place-bid with artwork row lock, bid_received/outbid email hooks via Resend (dev_otp when email unset), and artwork-page login + bid modal. --- .env.example | 4 +- README.md | 1 + api/auction/auth/request-link.js | 28 +++ api/auction/auth/session.js | 39 ++++ api/auction/auth/verify.js | 34 ++++ api/auction/bids.js | 34 ++++ api/auction/me.js | 25 +++ auction/index.html | 1 + docs/auction/README.md | 5 +- docs/auction/SLICE_2_RUNBOOK.md | 43 +++++ docs/auction/TODO.md | 16 +- lib/auction/bids.js | 183 ++++++++++++++++++ lib/auction/email.js | 92 +++++++++ lib/auction/index.js | 3 + lib/auction/login.js | 133 +++++++++++++ static/css/auction.css | 141 ++++++++++++++ static/js/auction.js | 318 ++++++++++++++++++++++++++++--- 17 files changed, 1061 insertions(+), 39 deletions(-) create mode 100644 api/auction/auth/request-link.js create mode 100644 api/auction/auth/session.js create mode 100644 api/auction/auth/verify.js create mode 100644 api/auction/bids.js create mode 100644 api/auction/me.js create mode 100644 docs/auction/SLICE_2_RUNBOOK.md create mode 100644 lib/auction/bids.js create mode 100644 lib/auction/email.js create mode 100644 lib/auction/login.js diff --git a/.env.example b/.env.example index 92b6e09..5bfc301 100644 --- a/.env.example +++ b/.env.example @@ -12,9 +12,11 @@ SITE_URL=http://localhost:4000 ADMIN_EMAIL=admin@hackerdojo.org ADMIN_NAME=Auction Admin -# D3 — Resend (Slice 4 emails; optional until then) +# 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/5 AUCTION_CRON_SECRET= diff --git a/README.md b/README.md index d531bcf..f29e954 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Fundraising silent auction for art (and donated lots), planned and implemented i | [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) | ### Auction bootstrap (Slices 0–1) 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/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/index.html b/auction/index.html index 64743b2..a8ee1cb 100644 --- a/auction/index.html +++ b/auction/index.html @@ -10,6 +10,7 @@

Auction Space

Browse fundraising 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.

diff --git a/docs/auction/README.md b/docs/auction/README.md index dcb5518..b355032 100644 --- a/docs/auction/README.md +++ b/docs/auction/README.md @@ -111,7 +111,8 @@ Details: [ROADMAP.md](./ROADMAP.md). | 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–1** — bootstrap + public browse (gallery, detail, countdown) | -| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 2 (Bid) | +| Application code | **Slices 0–2** — bootstrap, browse, login OTP + place bid | +| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 3 (Admin) | | Slice 0 runbook | [SLICE_0_RUNBOOK.md](./SLICE_0_RUNBOOK.md) | | Slice 1 runbook | [SLICE_1_RUNBOOK.md](./SLICE_1_RUNBOOK.md) | +| Slice 2 runbook | [SLICE_2_RUNBOOK.md](./SLICE_2_RUNBOOK.md) | 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/TODO.md b/docs/auction/TODO.md index f283747..22e6d06 100644 --- a/docs/auction/TODO.md +++ b/docs/auction/TODO.md @@ -43,14 +43,14 @@ Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.m ### Slice 2 — Bid -- [ ] **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). -- [ ] **T12** `POST /api/auction/bids` with transactional row lock + validation errors. -- [ ] **T13** Bid modal UI + success/error + refresh current bid on page. -- [ ] **T14** Manual concurrency check (two near-simultaneous bids). - -**Slice 2 done when:** logged-in user can place a valid bid; invalid/late bids fail cleanly. +- [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 diff --git a/lib/auction/bids.js b/lib/auction/bids.js new file mode 100644 index 0000000..8f4dde5 --- /dev/null +++ b/lib/auction/bids.js @@ -0,0 +1,183 @@ +/** + * Place bid with transactional row lock. + */ + +import { withTransaction } from './db.js'; +import { + parseMoney, + formatMoney, + minimumNextBid, + assertBidMeetsMinimum, + roundMoney, +} from './money.js'; +import { apiError, ErrorCodes } from './errors.js'; +import { serializeArtworkDetail } from './artworks.js'; +import { sendAuctionEmail, artworkUrl } 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) + const artUrl = artworkUrl(result.artwork.id); + const artTitle = result.artwork.title; + const minNextAfter = formatMoney( + minimumNextBid({ + starting_bid: result.artwork.starting_bid, + current_bid: result.artwork.current_bid, + minimum_increment: result.artwork.minimum_increment, + }) + ); + + try { + await sendAuctionEmail({ + userId: input.userId, + type: 'bid_received', + to: input.userEmail, + artworkId: result.artwork.id, + bidId: result.bid.id, + subject: `Bid received: ${artTitle}`, + text: [ + `Hi ${input.userName || 'there'},`, + '', + `We received your bid of $${formatMoney(result.bid.amount)} on "${artTitle}".`, + `Current high bid: $${formatMoney(result.artwork.current_bid)}`, + `Minimum next bid: $${minNextAfter}`, + `Ends: ${new Date(result.artwork.ends_at).toISOString()}`, + '', + artUrl, + ].join('\n'), + meta: { amount: formatMoney(result.bid.amount) }, + }); + } 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 sendAuctionEmail({ + userId: result.previousHigh.user_id, + type: 'outbid', + to: result.previousHigh.email, + artworkId: result.artwork.id, + bidId: result.bid.id, + subject: `You've been outbid on ${artTitle}`, + text: [ + `Hi ${result.previousHigh.name || 'there'},`, + '', + `Someone placed a higher bid on "${artTitle}".`, + `Your bid was $${formatMoney(result.previousHigh.amount)}.`, + `Current high bid: $${formatMoney(result.artwork.current_bid)}`, + `Minimum next bid: $${minNextAfter}`, + '', + artUrl, + ].join('\n'), + meta: { + your_amount: formatMoney(result.previousHigh.amount), + current_bid: formatMoney(result.artwork.current_bid), + }, + }); + } 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/email.js b/lib/auction/email.js new file mode 100644 index 0000000..85d3936 --- /dev/null +++ b/lib/auction/email.js @@ -0,0 +1,92 @@ +/** + * 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'; + +/** + * @param {{ + * userId: string, + * type: string, + * to: string, + * subject: string, + * text: string, + * html?: string, + * artworkId?: string | null, + * bidId?: string | null, + * meta?: Record + * }} opts + */ +export async function sendAuctionEmail(opts) { + const apiKey = getEmailApiKey(); + const from = getEmailFrom(); + + 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 || {}), + ] + ); + const notificationId = insert.rows[0].id; + + 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)}`; +} diff --git a/lib/auction/index.js b/lib/auction/index.js index 0124860..cb7d42a 100644 --- a/lib/auction/index.js +++ b/lib/auction/index.js @@ -10,3 +10,6 @@ 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'; \ No newline at end of file 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/static/css/auction.css b/static/css/auction.css index 9a1123d..93be1bc 100644 --- a/static/css/auction.css +++ b/static/css/auction.css @@ -307,3 +307,144 @@ font-size: 0.85rem; color: #6b7280; } + +.auction-session { + font-size: 0.9rem; + color: #5a6270; + margin: 0 0 10px; +} + +.auction-link-btn { + background: none; + border: none; + color: #e13838; + cursor: pointer; + font: inherit; + text-decoration: underline; + padding: 0; +} + +.auction-bid-cta.is-ready { + opacity: 1; + cursor: pointer; + pointer-events: auto; +} + +/* Modal */ + +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(12, 17, 29, 0.55); +} + +.auction-modal-dialog { + position: relative; + background: #fff; + border-radius: 16px; + max-width: 420px; + width: 100%; + padding: 24px 22px 20px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); + z-index: 1; +} + +.auction-modal-close { + position: absolute; + top: 10px; + right: 14px; + border: none; + background: transparent; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: #6b7280; +} + +.auction-modal-title { + font-family: 'Rajdhani', sans-serif; + font-size: 1.4rem; + font-weight: 700; + margin: 0 0 10px; + padding-right: 24px; +} + +.auction-modal-help { + color: #5a6270; + font-size: 0.95rem; + margin: 0 0 14px; + line-height: 1.45; +} + +.auction-modal-dev { + background: #fff7ed; + border: 1px solid #fed7aa; + color: #9a3412; + padding: 10px 12px; + border-radius: 8px; + font-size: 0.9rem; + margin: 0 0 12px; +} + +.auction-form label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 12px; +} + +.auction-form input { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 10px; + font: inherit; + box-sizing: border-box; +} + +.auction-form .optional { + font-weight: 400; + color: #9ca3af; +} + +.auction-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; +} + +.auction-modal-error { + color: #b91c1c; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 8px 10px; + font-size: 0.9rem; + margin: 0 0 10px; +} + +.auction-modal-error[hidden] { + display: none !important; +} diff --git a/static/js/auction.js b/static/js/auction.js index 5a039de..5803f42 100644 --- a/static/js/auction.js +++ b/static/js/auction.js @@ -1,11 +1,16 @@ /** - * Auction Space client helpers — gallery, detail, countdown. - * Configure API base with: window.HD_AUCTION_API = 'https://your-api.vercel.app' - * (empty = same origin) + * 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(/\/$/, ''); @@ -17,12 +22,20 @@ return apiBase() + path; } - async function fetchJson(path) { - const res = await fetch(apiUrl(path), { + async function fetchJson(path, options) { + var opts = options || {}; + var res = await fetch(apiUrl(path), { credentials: 'include', - headers: { Accept: 'application/json' }, + 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, }); - const data = await res.json().catch(function () { + if (res.status === 204) return null; + var data = await res.json().catch(function () { return null; }); if (!res.ok) { @@ -31,6 +44,7 @@ '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; } @@ -97,6 +111,229 @@ .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 @@ -137,7 +374,6 @@ async function mountGallery(el) { el.innerHTML = '
Loading lots…
'; try { - // Show active + preview + recently closed for a fuller gallery var data = await fetchJson('/api/auction/artworks?status=all_public&limit=50'); var list = (data && data.artworks) || []; if (!list.length) { @@ -179,20 +415,24 @@ } 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 img = + art.images && art.images[0] + ? '' +
+            escapeHtml(art.title) +
+            '' + : '
No image
'; var current = art.current_bid != null @@ -202,7 +442,7 @@ var bidRows = bids.length === 0 - ? '

No bids yet — be the first when bidding opens.

' + ? '

No bids yet — be the first!

' : '
    ' + bids .map(function (b) { @@ -220,15 +460,15 @@ '
'; var canBid = art.status === 'active' && new Date(art.ends_at) > new Date(); - var ctaClass = 'button button-red auction-bid-cta' + (canBid ? '' : ''); - // Slice 1: CTA visible but not interactive (Slice 2 enables bidding) - var cta = - '' + - '

Login and bidding land in the next auction slice. Countdown and prices refresh on reload.

'; + var sessionLine = state.user + ? '

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

' + : '

Not signed in

'; + + var cta = canBid + ? '' + : ''; el.innerHTML = '← All lots' + @@ -247,10 +487,10 @@ '
' + '
' + currentLabel + - '' + + '' + current + '
' + - '
Minimum next bid' + + '
Minimum next bid' + formatMoney(art.minimum_next_bid) + '
' + '
Ends in' + escapeHtml(art.description || '') + '
' + + sessionLine + cta + '

Recent bids

' + bidRows + @@ -267,6 +508,25 @@ '
'; 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 = '← All lots' + @@ -276,7 +536,7 @@ } } - function init() { + async function init() { var gallery = document.getElementById('auction-gallery'); if (gallery) mountGallery(gallery); @@ -303,5 +563,7 @@ formatMoney: formatMoney, formatCountdown: formatCountdown, bindCountdowns: bindCountdowns, + refreshSession: refreshSession, + startBidFlow: startBidFlow, }; })(); From 75d67d90cf16a548fdd517c0c46ea51b94ff8602 Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 4 Aug 2026 14:08:06 -0700 Subject: [PATCH 4/8] feat(auction): Slice 3 admin create, edit, and close Add admin artworks list/detail API, create/patch/delete-draft/close endpoints, and /auction/admin UI so staff can run lots without SQL. Close sets winner from high bid and sends winner/closed emails when Resend is configured. --- README.md | 1 + api/auction/admin/artworks.js | 37 +++ api/auction/artworks.js | 55 ++-- api/auction/artworks/[id].js | 58 +++- api/auction/artworks/[id]/close.js | 33 ++ auction/admin.html | 17 + docs/auction/README.md | 5 +- docs/auction/SLICE_3_RUNBOOK.md | 38 +++ docs/auction/TODO.md | 8 +- lib/auction/admin-artworks.js | 421 ++++++++++++++++++++++++ lib/auction/index.js | 5 +- static/css/auction.css | 172 ++++++++++ static/js/auction-admin.js | 511 +++++++++++++++++++++++++++++ 13 files changed, 1310 insertions(+), 51 deletions(-) create mode 100644 api/auction/admin/artworks.js create mode 100644 api/auction/artworks/[id]/close.js create mode 100644 auction/admin.html create mode 100644 docs/auction/SLICE_3_RUNBOOK.md create mode 100644 lib/auction/admin-artworks.js create mode 100644 static/js/auction-admin.js diff --git a/README.md b/README.md index f29e954..6d13da7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Fundraising silent auction for art (and donated lots), planned and implemented i | [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) | ### Auction bootstrap (Slices 0–1) 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/artworks.js b/api/auction/artworks.js index a33ca1d..4e04818 100644 --- a/api/auction/artworks.js +++ b/api/auction/artworks.js @@ -1,36 +1,41 @@ /** - * GET /api/auction/artworks - * Public list of auction lots. - * - * Query: status=active|preview|closed|all_public (default active) - * limit, offset + * GET /api/auction/artworks — public list + * POST /api/auction/artworks — admin create */ import { listPublicArtworks } from '../../lib/auction/artworks.js'; -import { json, withHandler } from '../../lib/auction/http.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 artworksList(req, res) { - if (req.method !== 'GET') { - res.statusCode = 405; - res.setHeader('Allow', 'GET, OPTIONS'); - return res.end(); - } +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'); - 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'); + } - 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 }); } - const artworks = await listPublicArtworks({ - status, - limit: limit ? Number(limit) : 50, - offset: offset ? Number(offset) : 0, - }); + 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 }); + } - return json(res, 200, { artworks }); -}, { methods: ['GET', 'OPTIONS'] }); + 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 index 0c27954..160a08d 100644 --- a/api/auction/artworks/[id].js +++ b/api/auction/artworks/[id].js @@ -1,30 +1,52 @@ /** - * GET /api/auction/artworks/:id - * Public artwork detail + recent bids. + * 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 { json, withHandler } from '../../../lib/auction/http.js'; - -export default withHandler(async function artworkDetail(req, res) { - if (req.method !== 'GET') { - res.statusCode = 405; - res.setHeader('Allow', 'GET, OPTIONS'); - return res.end(); - } +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) { - res.statusCode = 400; - return json(res, 400, { - error: { code: 'VALIDATION_ERROR', message: 'Missing artwork 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(); } - const payload = await getPublicArtworkDetail(decodeURIComponent(String(id))); - return json(res, 200, payload); -}, { methods: ['GET', 'OPTIONS'] }); + 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/auction/admin.html b/auction/admin.html new file mode 100644 index 0000000..4116f5c --- /dev/null +++ b/auction/admin.html @@ -0,0 +1,17 @@ +--- +layout: default +title: Auction Admin | Hacker Dojo +permalink: /auction/admin/ +--- + + +
+
+
Loading admin…
+
+
+ + + diff --git a/docs/auction/README.md b/docs/auction/README.md index b355032..8ee7dfc 100644 --- a/docs/auction/README.md +++ b/docs/auction/README.md @@ -111,8 +111,9 @@ Details: [ROADMAP.md](./ROADMAP.md). | 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–2** — bootstrap, browse, login OTP + place bid | -| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 3 (Admin) | +| Application code | **Slices 0–3** — bootstrap, browse, bid, admin CRUD/close | +| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 4 (Emails polish) | | Slice 0 runbook | [SLICE_0_RUNBOOK.md](./SLICE_0_RUNBOOK.md) | | Slice 1 runbook | [SLICE_1_RUNBOOK.md](./SLICE_1_RUNBOOK.md) | | Slice 2 runbook | [SLICE_2_RUNBOOK.md](./SLICE_2_RUNBOOK.md) | +| Slice 3 runbook | [SLICE_3_RUNBOOK.md](./SLICE_3_RUNBOOK.md) | 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/TODO.md b/docs/auction/TODO.md index 22e6d06..04a7c4f 100644 --- a/docs/auction/TODO.md +++ b/docs/auction/TODO.md @@ -54,11 +54,11 @@ Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.m ### Slice 3 — Admin -- [ ] **T19** Admin list/create API (`GET`/`POST` artworks). -- [ ] **T20** Admin patch + delete-draft + close (sets winner). -- [ ] **T21** Admin HTML page: table, editor form, bid list. +- [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 done when:** staff can create → activate → close a lot without DB access. → [SLICE_3_RUNBOOK.md](./SLICE_3_RUNBOOK.md) ### Slice 4 — Emails diff --git a/lib/auction/admin-artworks.js b/lib/auction/admin-artworks.js new file mode 100644 index 0000000..7b08b32 --- /dev/null +++ b/lib/auction/admin-artworks.js @@ -0,0 +1,421 @@ +/** + * Admin artwork CRUD + close (sets winner). + */ + +import { query, withTransaction } from './db.js'; +import { formatMoney, parseMoney, roundMoney } from './money.js'; +import { apiError, ErrorCodes } from './errors.js'; +import { + serializeArtworkDetail, + getArtworkById, + bidderDisplay, +} from './artworks.js'; +import { sendAuctionEmail, artworkUrl } 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 url = artworkUrl(id); + const title = result.artwork.title; + + if (result.winner?.email) { + try { + await sendAuctionEmail({ + userId: result.winner.user_id, + type: 'winner', + to: result.winner.email, + artworkId: id, + bidId: result.winner.id, + subject: `You won: ${title}`, + text: [ + `Hi ${result.winner.name || 'there'},`, + '', + `Congratulations — you won "${title}" with a bid of $${formatMoney(result.winner.amount)}.`, + 'Hacker Dojo will contact you about payment and pickup.', + '', + url, + ].join('\n'), + meta: { winning_amount: formatMoney(result.winner.amount) }, + }); + } catch (err) { + console.error('[auction/admin] winner email error', err); + } + } + + // Notify this admin (and any other admins lightly — MVP: acting admin only + log) + try { + await sendAuctionEmail({ + userId: admin.id, + type: 'auction_closed', + to: admin.email, + artworkId: id, + subject: `Auction closed: ${title}`, + text: [ + `Lot closed: "${title}"`, + result.winner + ? `Winner: ${result.winner.email} at $${formatMoney(result.winner.amount)}` + : 'Winner: none (no bids)', + '', + url, + ].join('\n'), + meta: { + winner_email: result.winner?.email || null, + winning_amount: result.winner ? formatMoney(result.winner.amount) : null, + }, + }); + } 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/index.js b/lib/auction/index.js index cb7d42a..0370779 100644 --- a/lib/auction/index.js +++ b/lib/auction/index.js @@ -1,5 +1,5 @@ /** - * Auction Space shared library — Slice 0 surface. + * Auction Space shared library. */ export * from './config.js'; @@ -12,4 +12,5 @@ export * from './users.js'; export * from './artworks.js'; export * from './email.js'; export * from './login.js'; -export * from './bids.js'; \ No newline at end of file +export * from './bids.js'; +export * from './admin-artworks.js'; diff --git a/static/css/auction.css b/static/css/auction.css index 93be1bc..76a4939 100644 --- a/static/css/auction.css +++ b/static/css/auction.css @@ -448,3 +448,175 @@ body.auction-modal-open { .auction-modal-error[hidden] { display: none !important; } + +/* Admin */ + +.admin-page .admin-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 8px; +} + +.admin-header-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.admin-toolbar { + margin: 12px 0 16px; +} + +.admin-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.9fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .admin-layout { + grid-template-columns: 1fr; + } +} + +.admin-table-wrap { + overflow-x: auto; + background: #fff; + border: 1px solid #e8e8ec; + border-radius: 12px; +} + +.admin-table, +.admin-bids-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.admin-table th, +.admin-table td, +.admin-bids-table th, +.admin-bids-table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid #f0f1f3; + vertical-align: middle; +} + +.admin-table th, +.admin-bids-table th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b7280; + background: #fafafa; +} + +.admin-row.is-selected { + background: #fff7ed; +} + +.admin-status { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-editor { + background: #fff; + border: 1px solid #e8e8ec; + border-radius: 12px; + padding: 16px 18px 20px; +} + +.admin-editor h2, +.admin-editor h3 { + font-family: 'Rajdhani', sans-serif; + margin: 0 0 12px; +} + +.admin-editor textarea { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 10px; + font: inherit; + box-sizing: border-box; + resize: vertical; +} + +.admin-editor select { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 10px; + font: inherit; + background: #fff; +} + +.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: 24px; + padding-top: 16px; + border-top: 1px solid #f0f1f3; +} + +.admin-flash { + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #065f46; + padding: 10px 12px; + border-radius: 10px; + margin-bottom: 12px; + font-size: 0.95rem; +} + +.admin-flash.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #b91c1c; +} + +.admin-flash[hidden] { + display: none !important; +} + +.admin-login-form { + max-width: 360px; + background: #fff; + border: 1px solid #e8e8ec; + border-radius: 12px; + padding: 16px; +} + +.admin-edit-btn { + padding: 6px 12px; + font-size: 0.85rem; +} diff --git a/static/js/auction-admin.js b/static/js/auction-admin.js new file mode 100644 index 0000000..0d5f825 --- /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 = + '
' + + '

Admin · Silent Auction

' + + '
' + + 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(); +})(); From 7883aa96ae2a490e52d46361c6bdc91358f6043e Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 4 Aug 2026 14:12:24 -0700 Subject: [PATCH 5/8] feat(auction): Slice 4 transactional emails and cron Polish bid/outbid/winner/closed templates with shared footers, notify all admins on close, and add secured ending-soon cron with idempotent sends plus auto-close for lots past ends_at. Hourly schedule in vercel.json. --- .env.example | 7 +- README.md | 1 + api/auction/cron/ending-soon.js | 51 ++++++ docs/auction/README.md | 9 +- docs/auction/SLICE_4_RUNBOOK.md | 63 +++++++ docs/auction/TODO.md | 10 +- lib/auction/admin-artworks.js | 67 +++---- lib/auction/bids.js | 55 +----- lib/auction/cron.js | 137 ++++++++++++++ lib/auction/email.js | 312 ++++++++++++++++++++++++++++++-- lib/auction/index.js | 1 + vercel.json | 8 +- 12 files changed, 608 insertions(+), 113 deletions(-) create mode 100644 api/auction/cron/ending-soon.js create mode 100644 docs/auction/SLICE_4_RUNBOOK.md create mode 100644 lib/auction/cron.js diff --git a/.env.example b/.env.example index 5bfc301..fef34f8 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ EMAIL_FROM="Hacker Dojo Auction " # Force OTP in API JSON even in production-like envs (local only) # AUCTION_DEV_OTP=1 -# Slice 4/5 +# 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/README.md b/README.md index 6d13da7..2f63237 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Fundraising silent auction for art (and donated lots), planned and implemented i | [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) 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/docs/auction/README.md b/docs/auction/README.md index 8ee7dfc..a130cd0 100644 --- a/docs/auction/README.md +++ b/docs/auction/README.md @@ -111,9 +111,6 @@ Details: [ROADMAP.md](./ROADMAP.md). | 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–3** — bootstrap, browse, bid, admin CRUD/close | -| Implementation | Phase 1 in progress; next is [SLICES.md](./SLICES.md) Slice 4 (Emails polish) | -| Slice 0 runbook | [SLICE_0_RUNBOOK.md](./SLICE_0_RUNBOOK.md) | -| Slice 1 runbook | [SLICE_1_RUNBOOK.md](./SLICE_1_RUNBOOK.md) | -| Slice 2 runbook | [SLICE_2_RUNBOOK.md](./SLICE_2_RUNBOOK.md) | -| Slice 3 runbook | [SLICE_3_RUNBOOK.md](./SLICE_3_RUNBOOK.md) | +| 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) | 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/TODO.md b/docs/auction/TODO.md index 04a7c4f..bd5b7fc 100644 --- a/docs/auction/TODO.md +++ b/docs/auction/TODO.md @@ -62,12 +62,12 @@ Prefer **one PR per slice**. Each slice must meet its **Done when** in [SLICES.m ### Slice 4 — Emails -- [ ] **T15** Email send helper + `Notification` write on success/failure. -- [ ] **T16** `bid_received` + `outbid` from bid handler. -- [ ] **T17** `winner` + `auction_closed` on close. -- [ ] **T18** Secured cron for `auction_ending_soon` + idempotency. +- [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 done when:** emails in [EMAILS.md](./EMAILS.md) send for the happy path. → [SLICE_4_RUNBOOK.md](./SLICE_4_RUNBOOK.md) ### Slice 5 — Ship → minimal fully functional diff --git a/lib/auction/admin-artworks.js b/lib/auction/admin-artworks.js index 7b08b32..acc0523 100644 --- a/lib/auction/admin-artworks.js +++ b/lib/auction/admin-artworks.js @@ -3,14 +3,18 @@ */ import { query, withTransaction } from './db.js'; -import { formatMoney, parseMoney, roundMoney } from './money.js'; +import { parseMoney, roundMoney } from './money.js'; import { apiError, ErrorCodes } from './errors.js'; import { serializeArtworkDetail, getArtworkById, bidderDisplay, } from './artworks.js'; -import { sendAuctionEmail, artworkUrl } from './email.js'; +import { + notifyWinner, + notifyAuctionClosed, + listAdminRecipients, +} from './email.js'; const STATUSES = new Set(['draft', 'preview', 'active', 'closed']); @@ -358,54 +362,43 @@ export async function closeArtwork(id, admin) { const serialized = serializeArtworkDetail(result.artwork); if (!result.alreadyClosed) { - const url = artworkUrl(id); - const title = result.artwork.title; + 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 sendAuctionEmail({ + await notifyWinner({ userId: result.winner.user_id, - type: 'winner', to: result.winner.email, - artworkId: id, + name: result.winner.name, + art: result.artwork, + winningAmount: result.winner.amount, bidId: result.winner.id, - subject: `You won: ${title}`, - text: [ - `Hi ${result.winner.name || 'there'},`, - '', - `Congratulations — you won "${title}" with a bid of $${formatMoney(result.winner.amount)}.`, - 'Hacker Dojo will contact you about payment and pickup.', - '', - url, - ].join('\n'), - meta: { winning_amount: formatMoney(result.winner.amount) }, }); } catch (err) { console.error('[auction/admin] winner email error', err); } } - // Notify this admin (and any other admins lightly — MVP: acting admin only + log) try { - await sendAuctionEmail({ - userId: admin.id, - type: 'auction_closed', - to: admin.email, - artworkId: id, - subject: `Auction closed: ${title}`, - text: [ - `Lot closed: "${title}"`, - result.winner - ? `Winner: ${result.winner.email} at $${formatMoney(result.winner.amount)}` - : 'Winner: none (no bids)', - '', - url, - ].join('\n'), - meta: { - winner_email: result.winner?.email || null, - winning_amount: result.winner ? formatMoney(result.winner.amount) : null, - }, - }); + 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); } diff --git a/lib/auction/bids.js b/lib/auction/bids.js index 8f4dde5..0d48255 100644 --- a/lib/auction/bids.js +++ b/lib/auction/bids.js @@ -6,13 +6,12 @@ import { withTransaction } from './db.js'; import { parseMoney, formatMoney, - minimumNextBid, assertBidMeetsMinimum, roundMoney, } from './money.js'; import { apiError, ErrorCodes } from './errors.js'; import { serializeArtworkDetail } from './artworks.js'; -import { sendAuctionEmail, artworkUrl } from './email.js'; +import { notifyBidReceived, notifyOutbid } from './email.js'; /** * @param {{ artworkId: string, userId: string, amount: unknown, userEmail: string, userName?: string | null }} input @@ -94,35 +93,13 @@ export async function placeBid(input) { }); // Emails after commit (do not fail the bid if mail fails) - const artUrl = artworkUrl(result.artwork.id); - const artTitle = result.artwork.title; - const minNextAfter = formatMoney( - minimumNextBid({ - starting_bid: result.artwork.starting_bid, - current_bid: result.artwork.current_bid, - minimum_increment: result.artwork.minimum_increment, - }) - ); - try { - await sendAuctionEmail({ + await notifyBidReceived({ userId: input.userId, - type: 'bid_received', to: input.userEmail, - artworkId: result.artwork.id, - bidId: result.bid.id, - subject: `Bid received: ${artTitle}`, - text: [ - `Hi ${input.userName || 'there'},`, - '', - `We received your bid of $${formatMoney(result.bid.amount)} on "${artTitle}".`, - `Current high bid: $${formatMoney(result.artwork.current_bid)}`, - `Minimum next bid: $${minNextAfter}`, - `Ends: ${new Date(result.artwork.ends_at).toISOString()}`, - '', - artUrl, - ].join('\n'), - meta: { amount: formatMoney(result.bid.amount) }, + name: input.userName, + art: result.artwork, + bid: result.bid, }); } catch (err) { console.error('[auction/bids] bid_received email error', err); @@ -134,27 +111,13 @@ export async function placeBid(input) { result.previousHigh.email ) { try { - await sendAuctionEmail({ + await notifyOutbid({ userId: result.previousHigh.user_id, - type: 'outbid', to: result.previousHigh.email, - artworkId: result.artwork.id, + name: result.previousHigh.name, + art: result.artwork, + yourAmount: result.previousHigh.amount, bidId: result.bid.id, - subject: `You've been outbid on ${artTitle}`, - text: [ - `Hi ${result.previousHigh.name || 'there'},`, - '', - `Someone placed a higher bid on "${artTitle}".`, - `Your bid was $${formatMoney(result.previousHigh.amount)}.`, - `Current high bid: $${formatMoney(result.artwork.current_bid)}`, - `Minimum next bid: $${minNextAfter}`, - '', - artUrl, - ].join('\n'), - meta: { - your_amount: formatMoney(result.previousHigh.amount), - current_bid: formatMoney(result.artwork.current_bid), - }, }); } catch (err) { console.error('[auction/bids] outbid email error', err); 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/email.js b/lib/auction/email.js index 85d3936..27f6208 100644 --- a/lib/auction/email.js +++ b/lib/auction/email.js @@ -5,6 +5,12 @@ 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 {{ @@ -16,26 +22,54 @@ import { query } from './db.js'; * html?: string, * artworkId?: string | null, * bidId?: string | null, - * meta?: Record + * 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(); - 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 || {}), - ] - ); - const notificationId = insert.rows[0].id; + 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( @@ -90,3 +124,253 @@ export function shouldExposeDevOtp() { 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/index.js b/lib/auction/index.js index 0370779..c9711cd 100644 --- a/lib/auction/index.js +++ b/lib/auction/index.js @@ -14,3 +14,4 @@ export * from './email.js'; export * from './login.js'; export * from './bids.js'; export * from './admin-artworks.js'; +export * from './cron.js'; diff --git a/vercel.json b/vercel.json index 771856d..bc74d18 100644 --- a/vercel.json +++ b/vercel.json @@ -4,13 +4,13 @@ "functions": { "api/**/*.js": { "memory": 256, - "maxDuration": 10 + "maxDuration": 30 } }, - "rewrites": [ + "crons": [ { - "source": "/api/(.*)", - "destination": "/api/$1" + "path": "/api/auction/cron/ending-soon", + "schedule": "0 * * * *" } ] } From 8e6f02ea0a952b16c7ef5114ac3c125a816fe2e4 Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 4 Aug 2026 14:16:39 -0700 Subject: [PATCH 6/8] style(auction): align UI with hackerdojo.org design system Match silent auction pages to existing site language: section titles, Rajdhani/Saira type, #e13838 / #df3f33 reds, peachpuff card hover, shared button styles, soft purple hero wash, and 15px white cards with site shadow tokens. Update gallery, detail, modal, and admin chrome. --- auction/admin.html | 8 +- auction/artwork.html | 8 +- auction/index.html | 22 +- static/css/auction.css | 752 +++++++++++++++++++++++++------------ static/js/auction-admin.js | 28 +- static/js/auction.js | 23 +- 6 files changed, 568 insertions(+), 273 deletions(-) diff --git a/auction/admin.html b/auction/admin.html index 4116f5c..f4a7a4f 100644 --- a/auction/admin.html +++ b/auction/admin.html @@ -5,9 +5,11 @@ --- -
-
-
Loading admin…
+
+
+
+
Loading admin…
+
diff --git a/auction/artwork.html b/auction/artwork.html index 9842b95..5f62dc0 100644 --- a/auction/artwork.html +++ b/auction/artwork.html @@ -5,9 +5,11 @@ --- -
-
-
Loading artwork…
+
+
+
+
Loading artwork…
+
diff --git a/auction/index.html b/auction/index.html index a8ee1cb..9ec4024 100644 --- a/auction/index.html +++ b/auction/index.html @@ -5,17 +5,25 @@ --- -
-
Silent Auction
-

Auction Space

-

- Browse fundraising lots and place your bid before the countdown ends. +

+
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.

+
-