Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Auction Space — copy to .env.local (never commit secrets)
# See docs/auction/DECISIONS.md

# D1 — Postgres (Neon, Supabase, or any hosted Postgres)
DATABASE_URL=postgres://user:password@host:5432/hackerdojo_auction?sslmode=require

# D2 — Sessions (generate with: openssl rand -hex 32)
SESSION_SECRET=replace-with-at-least-32-random-bytes
SITE_URL=http://localhost:4000

# Slice 0 — admin seed
ADMIN_EMAIL=admin@hackerdojo.org
ADMIN_NAME=Auction Admin

# D3 — Resend (login OTP + bid emails; optional for local — returns dev_otp)
RESEND_API_KEY=
EMAIL_FROM="Hacker Dojo Auction <auction@hackerdojo.org>"
# Force OTP in API JSON even in production-like envs (local only)
# AUCTION_DEV_OTP=1

# Slice 4 — cron (required for ending-soon + auto-close job)
AUCTION_CRON_SECRET=
# Hours before end to send "ending soon" (default 24)
# AUCTION_ENDING_SOON_HOURS=24
# AUCTION_PICKUP_BLURB=Staff will contact you about payment and pickup.

# Slice 5
CORS_ORIGIN=http://localhost:4000,https://hackerdojo.org
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
.gstack
.vercel
.wrangler
node_modules/
.env
.env.local
.env.*.local
package-lock.json.bak

**/*.*~

4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ source "https://rubygems.org"

gem "jekyll", "~> 4.3.0"
gem "minima", "~> 2.5"
# Ruby 3.4+ / 4.x — no longer default gems
gem "csv"
gem "base64"
gem "logger"

group :jekyll_plugins do
gem "jekyll-feed"
Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ GEM
specs:
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bigdecimal (4.1.2)
colorator (1.1.0)
concurrent-ruby (1.3.6)
csv (3.3.6)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
Expand Down Expand Up @@ -159,16 +161,21 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
base64
csv
jekyll (~> 4.3.0)
jekyll-feed
logger
minima (~> 2.5)

CHECKSUMS
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785
colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ Once all pre-requisites are installed, you can preview the website using:
jekyll serve
```

## Auction Space (Silent Auction)

Fundraising silent auction for art (and donated lots), planned and implemented in slices on top of this site.

| Doc | Purpose |
|-----|---------|
| [docs/auction/START_HERE.md](./docs/auction/START_HERE.md) | Reading order |
| [docs/auction/DECISIONS.md](./docs/auction/DECISIONS.md) | Infrastructure choices (Postgres, magic-link, Resend, Vercel) |
| [docs/auction/SLICES.md](./docs/auction/SLICES.md) | Build order |
| [docs/auction/SLICE_0_RUNBOOK.md](./docs/auction/SLICE_0_RUNBOOK.md) | Bootstrap (DB migrate + admin seed) |
| [docs/auction/SLICE_1_RUNBOOK.md](./docs/auction/SLICE_1_RUNBOOK.md) | Browse (gallery + detail + countdown) |
| [docs/auction/SLICE_2_RUNBOOK.md](./docs/auction/SLICE_2_RUNBOOK.md) | Bid (OTP login + place bid) |
| [docs/auction/SLICE_3_RUNBOOK.md](./docs/auction/SLICE_3_RUNBOOK.md) | Admin (create / edit / close) |
| [docs/auction/SLICE_4_RUNBOOK.md](./docs/auction/SLICE_4_RUNBOOK.md) | Emails + ending-soon / auto-close cron |

### Auction bootstrap (Slices 0–1)

Requires Node 20+ and a Postgres `DATABASE_URL`.

```sh
cp .env.example .env.local # set DATABASE_URL, SESSION_SECRET, ADMIN_EMAIL
npm install
npm run auction:bootstrap # migrate + seed admin + demo lot
```

- Health: `GET /api/auction/health`
- Gallery: `/auction/`
- Lot detail: `/auction/artwork/?id=<uuid>`
## Silent Auction MVP (Planning)

See [docs/auction](./docs/auction).
1 change: 1 addition & 0 deletions _includes/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<a href="https://www.meetup.com/hackerdojo/events/">Accelerator</a>
<a href="https://www.meetup.com/hackerdojo/events/">AI Stars</a>
<a href="https://www.meetup.com/hackerdojo/events/">Summer Camp</a>
<a href="/auction/">Auction</a>
<a href="https://wiki.hackerdojo.com/index.php?title=Main_Page">Wiki</a>
</div>
<div>
Expand Down
2 changes: 2 additions & 0 deletions _includes/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<a href="/startups/">Startups</a>
<a href="/pricing/">Pricing</a>
<a href="/impact-report/">Impact Report</a>
<a href="/auction/" class="header-auction-button">Auction</a>
<a href="https://fundrazr.com/52kkYb?ref=sh_2F3E51_ab_0XDyBWOLHcp0XDyBWOLHcp" target="_blank" class="mobile-donate-button-header" style="">Donate</a>
</div>

Expand Down Expand Up @@ -87,6 +88,7 @@
<a href="/startups/">Startups</a>
<a href="/pricing/">Pricing</a>
<a href="/impact-report/" style="font-weight: 500 !important;">Impact Report</a>
<a href="/auction/" class="header-auction-button" style="font-weight: 600 !important; margin: 8px 15px;">Auction</a>
<a href="https://fundrazr.com/52kkYb?ref=sh_2F3E51_ab_0XDyBWOLHcp0XDyBWOLHcp" target="_blank" class="mobile-donate-button-header" style="">Donate</a>
<a href="https://dojo-earth.vercel.app" target="_blank">Dojo Earth 🌍</a>
<a href="https://calendly.com/hdfrontdesk/30min">Take A Tour</a>
Expand Down
37 changes: 37 additions & 0 deletions api/auction/admin/artworks.js
Original file line number Diff line number Diff line change
@@ -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'] });
36 changes: 36 additions & 0 deletions api/auction/admin/ping.js
Original file line number Diff line number Diff line change
@@ -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'] });
41 changes: 41 additions & 0 deletions api/auction/artworks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* GET /api/auction/artworks — public list
* POST /api/auction/artworks — admin create
*/

import { listPublicArtworks } from '../../lib/auction/artworks.js';
import { createArtwork } from '../../lib/auction/admin-artworks.js';
import { requireAdmin } from '../../lib/auction/auth.js';
import { readJsonBody, json, withHandler } from '../../lib/auction/http.js';
import { apiError, ErrorCodes } from '../../lib/auction/errors.js';

export default withHandler(async function artworks(req, res) {
if (req.method === 'GET') {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const status = url.searchParams.get('status') || 'active';
const limit = url.searchParams.get('limit');
const offset = url.searchParams.get('offset');

if (status && !['active', 'preview', 'closed', 'all_public'].includes(status)) {
throw apiError(ErrorCodes.VALIDATION_ERROR, 'Invalid status filter');
}

const artworks = await listPublicArtworks({
status,
limit: limit ? Number(limit) : 50,
offset: offset ? Number(offset) : 0,
});
return json(res, 200, { artworks });
}

if (req.method === 'POST') {
const admin = await requireAdmin(req);
const body = await readJsonBody(req);
const artwork = await createArtwork(body, admin.id);
return json(res, 201, { artwork });
}

res.statusCode = 405;
res.setHeader('Allow', 'GET, POST, OPTIONS');
return res.end();
}, { methods: ['GET', 'POST', 'OPTIONS'] });
52 changes: 52 additions & 0 deletions api/auction/artworks/[id].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* GET /api/auction/artworks/:id — public detail
* PATCH /api/auction/artworks/:id — admin update
* DELETE /api/auction/artworks/:id — admin delete draft
*/

import { getPublicArtworkDetail } from '../../../lib/auction/artworks.js';
import {
patchArtwork,
deleteDraftArtwork,
} from '../../../lib/auction/admin-artworks.js';
import { requireAdmin } from '../../../lib/auction/auth.js';
import { readJsonBody, json, withHandler } from '../../../lib/auction/http.js';
import { apiError, ErrorCodes } from '../../../lib/auction/errors.js';

function artworkId(req) {
const id =
req.query?.id ||
(req.url && req.url.match(/\/artworks\/([^/?#]+)/)?.[1]) ||
null;
if (!id) {
throw apiError(ErrorCodes.VALIDATION_ERROR, 'Missing artwork id');
}
return decodeURIComponent(String(id));
}

export default withHandler(async function artworkById(req, res) {
const id = artworkId(req);

if (req.method === 'GET') {
const payload = await getPublicArtworkDetail(id);
return json(res, 200, payload);
}

if (req.method === 'PATCH') {
const admin = await requireAdmin(req);
const body = await readJsonBody(req);
const artwork = await patchArtwork(id, body, admin.id);
return json(res, 200, { artwork });
}

if (req.method === 'DELETE') {
const admin = await requireAdmin(req);
await deleteDraftArtwork(id, admin.id);
res.statusCode = 204;
return res.end();
}

res.statusCode = 405;
res.setHeader('Allow', 'GET, PATCH, DELETE, OPTIONS');
return res.end();
}, { methods: ['GET', 'PATCH', 'DELETE', 'OPTIONS'] });
33 changes: 33 additions & 0 deletions api/auction/artworks/[id]/close.js
Original file line number Diff line number Diff line change
@@ -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'] });
28 changes: 28 additions & 0 deletions api/auction/auth/request-link.js
Original file line number Diff line number Diff line change
@@ -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'] });
Loading