@profullstack/builtindie 0.1.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Sign in with CoinPay: the house identity provider (OIDC at coinpayportal.com,
3
+ * authorization code + PKCE S256), through the shared @profullstack/stack/coinpay
4
+ * helpers. Same flow as pwamart.com, so one CoinPay account is a member of both.
5
+ *
6
+ * Linking is by CoinPay `sub` only (user_identities), never by email: CoinPay
7
+ * does not verify emails (email_verified is always false). So:
8
+ * - a returning sub signs in as the member it is linked to;
9
+ * - a signed-in member who connects (from /me) gets the sub attached;
10
+ * - anyone else gets a new member, with their CoinPay name as the display name
11
+ * and a placeholder address (…@coinpay.invalid): the CoinPay email is never
12
+ * used as the account's email, or it would let a CoinPay account sign in as
13
+ * whoever owns that address here. An address that already has an account
14
+ * is refused: that person signs in by email and connects CoinPay from /me.
15
+ *
16
+ * The state and PKCE verifier ride in a short HttpOnly cookie scoped to
17
+ * /api/v1/coinpay. Off (buttons hidden, routes redirect) without a client id and secret.
18
+ */
19
+ import { createHash, randomBytes } from 'node:crypto'
20
+ import { exchangeCoinPayCode, fetchCoinPayUserinfo, generateCoinPayPkcePair, getCoinPayAuthorizeUrl, validateCoinPayState } from '@profullstack/stack/coinpay'
21
+ import type { Context, Hono } from 'hono'
22
+ import { getCookie, setCookie } from 'hono/cookie'
23
+ import { safeNext, SESSION_COOKIE, type Auth } from './auth.js'
24
+ import type { Store, User } from './db.js'
25
+
26
+ export type CoinPayOAuth = { clientId: string; clientSecret: string; issuer: string; /** Tests pass a fake CoinPay. */ fetch?: typeof fetch }
27
+
28
+ export const PROVIDER = 'coinpay'
29
+ const COOKIE = 'bi_cp'
30
+ const PATH = '/api/v1/coinpay'
31
+ const SCOPES = 'openid profile email'
32
+
33
+ export function coinpayOAuthFromEnv(env: Record<string, string | undefined> = process.env): CoinPayOAuth | null {
34
+ const clientId = env.COINPAY_OAUTH_CLIENT_ID?.trim()
35
+ const clientSecret = env.COINPAY_OAUTH_CLIENT_SECRET?.trim()
36
+ return clientId && clientSecret ? { clientId, clientSecret, issuer: (env.COINPAY_OAUTH_ISSUER ?? 'https://coinpayportal.com').replace(/\/+$/, '') } : null
37
+ }
38
+
39
+ /** The address a CoinPay-only member is kept under: unique, and never deliverable. */
40
+ export const placeholderEmail = (sub: string) => `cp-${createHash('sha256').update(sub).digest('hex').slice(0, 20)}@coinpay.invalid`
41
+ export const isPlaceholder = (email: string) => email.endsWith('@coinpay.invalid')
42
+
43
+ export function mountCoinPaySignin(
44
+ app: Hono<any>,
45
+ o: { store: Store; auth: Auth; origin: string; oauth: CoinPayOAuth | null; secure: (c: Context) => boolean; sessionUser: (c: Context) => User | null; onNewMember?: (c: Context, user: User) => void },
46
+ ): void {
47
+ const redirectUri = `${o.origin}${PATH}/callback`
48
+ const cookie = (c: Context, value: string, maxAge: number) => setCookie(c, COOKIE, value, { httpOnly: true, secure: o.secure(c), sameSite: 'Lax', path: PATH, maxAge })
49
+
50
+ app.get(`${PATH}/login`, (c) => {
51
+ if (!o.oauth) return c.redirect('/login?error=coinpay-off')
52
+ const state = randomBytes(24).toString('base64url')
53
+ const { codeVerifier, codeChallenge } = generateCoinPayPkcePair()
54
+ const next = safeNext(c.req.query('next')) ?? '/me'
55
+ cookie(c, Buffer.from(JSON.stringify({ state, codeVerifier, next })).toString('base64url'), 600)
56
+ return c.redirect(getCoinPayAuthorizeUrl({ issuer: o.oauth.issuer, clientId: o.oauth.clientId, redirectUri, state, codeChallenge, scopes: SCOPES }))
57
+ })
58
+
59
+ app.get(`${PATH}/callback`, async (c) => {
60
+ const fail = (why: string) => c.redirect(`/login?error=${encodeURIComponent(why)}`)
61
+ if (!o.oauth) return fail('coinpay-off')
62
+ let saved: { state?: string; codeVerifier?: string; next?: string } | null = null
63
+ try {
64
+ saved = JSON.parse(Buffer.from(getCookie(c, COOKIE) ?? '', 'base64url').toString())
65
+ } catch {}
66
+ cookie(c, '', 0)
67
+ if (!saved?.state || !saved.codeVerifier) return fail('coinpay-expired')
68
+ if (c.req.query('error')) return fail('coinpay-denied')
69
+ if (!validateCoinPayState(c.req.query('state'), saved.state)) return fail('coinpay-state')
70
+ const code = c.req.query('code')
71
+ if (!code) return fail('coinpay-failed')
72
+
73
+ let claims: { sub?: string; email?: string; name?: string }
74
+ try {
75
+ const tokens = await exchangeCoinPayCode({ issuer: o.oauth.issuer, code, redirectUri, clientId: o.oauth.clientId, clientSecret: o.oauth.clientSecret, codeVerifier: saved.codeVerifier, fetch: o.oauth.fetch })
76
+ claims = await fetchCoinPayUserinfo({ issuer: o.oauth.issuer, accessToken: tokens.access_token, fetch: o.oauth.fetch })
77
+ } catch (e) {
78
+ console.error(`[coinpay-signin] ${(e as Error).message}`)
79
+ return fail('coinpay-failed')
80
+ }
81
+ const sub = String(claims.sub ?? '')
82
+ if (!sub) return fail('coinpay-failed')
83
+ const email = String(claims.email ?? '').trim().toLowerCase() || null
84
+ const name = String(claims.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40) || null
85
+ const next = safeNext(saved.next) ?? '/me'
86
+
87
+ // Connecting CoinPay to the member who is signed in.
88
+ const current = o.sessionUser(c)
89
+ if (current) {
90
+ if (!o.store.linkIdentity({ provider: PROVIDER, subject: sub, userId: current.id, email, name })) return c.redirect('/me?coinpay=taken#signin')
91
+ return c.redirect('/me?coinpay=connected#signin')
92
+ }
93
+
94
+ let user = o.store.identityUser(PROVIDER, sub)
95
+ let created = false
96
+ if (!user) {
97
+ if (email && o.store.userByEmail(email)) return fail('coinpay-email-exists')
98
+ user = o.store.findOrCreateUser(placeholderEmail(sub))
99
+ created = true
100
+ o.store.linkIdentity({ provider: PROVIDER, subject: sub, userId: user.id, email, name })
101
+ if (name && !user.displayName) o.store.setDisplayName(user.id, name)
102
+ user = o.store.user(user.id)!
103
+ o.onNewMember?.(c, user)
104
+ }
105
+ setCookie(c, SESSION_COOKIE, o.auth.createSession(user.id), { httpOnly: true, secure: o.secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
106
+ return c.redirect(created && next === '/me' ? '/me?coinpay=welcome' : next)
107
+ })
108
+ }
109
+
110
+ /** What /login says for each ?error= the flow can end with. */
111
+ export const COINPAY_ERRORS: Record<string, string> = {
112
+ 'coinpay-off': 'Sign in with CoinPay is not set up on this server. Use your email instead.',
113
+ 'coinpay-expired': 'That CoinPay sign-in took too long. Try again.',
114
+ 'coinpay-denied': 'CoinPay sign-in was cancelled.',
115
+ 'coinpay-state': 'That CoinPay sign-in did not match this browser. Try again.',
116
+ 'coinpay-failed': 'CoinPay did not confirm who you are. Try again in a moment.',
117
+ 'coinpay-email-exists': 'That email already has a built.indie account. Sign in with your email, then connect CoinPay on your page.',
118
+ }
package/src/db.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { Database } from 'bun:sqlite'
12
12
  import { createHash, createHmac, randomBytes } from 'node:crypto'
13
13
  import type { Pricing, Profile, Stack, Video } from './fetch.js'
14
+ import { inferTags } from './tags.js'
14
15
 
15
16
  /** What a product says about itself in the other LogicSRC files: OpenStack.md, OpenSaaS, OpenIcon. */
16
17
  export type Extras = { stack: Stack | null; pricing: Pricing | null; openicon: string | null }
@@ -46,6 +47,9 @@ export type Product = {
46
47
  submitterUser: string | null
47
48
  status: Status
48
49
  votes: number
50
+ /** Reviews: how many, and the mean of their 1-5 stars (null with none). */
51
+ reviews: number
52
+ rating: number | null
49
53
  verifiedAt: number | null
50
54
  featuredUntil: number | null
51
55
  launchedAt: number | null
@@ -53,7 +57,19 @@ export type Product = {
53
57
  updatedAt: number
54
58
  }
55
59
 
56
- export type User = { id: string; email: string; createdAt: number }
60
+ export type User = { id: string; email: string; createdAt: number; displayName: string | null }
61
+
62
+ /** Where a member stands in the invite program. A credit is one Featured week, free. */
63
+ export type InviteStats = { code: string; invited: number; launched: number; credits: number; used: number; /** Of `credits`, how many came from karma (one per KARMA_PER_CREDIT). */ karmaCredits: number }
64
+
65
+ /** One row of the karma ledger. Points may be negative: a reversal is a new row, never an edit. */
66
+ export type KarmaEvent = { id: string; userId: string; action: string; ref: string; points: number; createdAt: number }
67
+
68
+ /** A comment on a listing. Threaded one level: a reply's parent is always a top-level comment. */
69
+ export type Comment = { id: string; productId: string; userId: string; parentId: string | null; body: string; createdAt: number; deletedAt: number | null }
70
+
71
+ /** One member's review of a listing: 1-5 stars and some words. One per member per listing. */
72
+ export type Review = { productId: string; userId: string; rating: number; body: string; createdAt: number; updatedAt: number }
57
73
 
58
74
  export type Maker = { id: string; name: string; profileUrl: string; headline: string; avatar: string | null; web: string | null; accounts: string[]; createdAt: number }
59
75
 
@@ -64,7 +80,7 @@ export const newId = (bytes = 9) => randomBytes(bytes).toString('base64url')
64
80
  export const makerIdFor = (profileUrl: string) => createHash('sha256').update(profileUrl.toLowerCase()).digest('base64url').slice(0, 10)
65
81
 
66
82
  /** Paths that are pages, never product slugs. */
67
- const RESERVED = new Set(['submit', 'new', 'top', 'api', 'm', 'p', 'b', 'makers', 'help', 'about', 'feed', 'badge', 'hooks', 'health', 'ring', 'well-known', 'admin', 'today', 'week', 'month', 'all', 'featured', 'edit'])
83
+ const RESERVED = new Set(['submit', 'new', 'top', 'api', 'm', 'p', 'b', 'makers', 'help', 'about', 'feed', 'badge', 'hooks', 'health', 'ring', 'well-known', 'admin', 'today', 'week', 'month', 'all', 'featured', 'edit', 'i', 'invite', 'leaderboard', 't', 'tags'])
68
84
 
69
85
  export function slugify(name: string): string {
70
86
  const s = name
@@ -152,6 +168,80 @@ export class Store {
152
168
  accounts TEXT NOT NULL DEFAULT '[]',
153
169
  created_at INTEGER NOT NULL
154
170
  );
171
+ CREATE TABLE IF NOT EXISTS badge_ids (
172
+ id TEXT PRIMARY KEY,
173
+ product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
174
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
175
+ created_at INTEGER NOT NULL,
176
+ UNIQUE (product_id, user_id)
177
+ );
178
+ CREATE TABLE IF NOT EXISTS invite_codes (code TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL);
179
+ -- One row per member who joined through someone's invite link. It doubles as the
180
+ -- credit ledger: earned when their first listing goes live, redeemed for a Featured week.
181
+ CREATE TABLE IF NOT EXISTS invites (
182
+ invitee_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
183
+ inviter_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
184
+ created_at INTEGER NOT NULL,
185
+ product_id TEXT,
186
+ earned_at INTEGER,
187
+ redeemed_product TEXT,
188
+ redeemed_at INTEGER
189
+ );
190
+ CREATE INDEX IF NOT EXISTS invites_inviter ON invites(inviter_id, earned_at);
191
+ -- Karma: an append-only ledger. dedupe makes a retry or a double click pay once.
192
+ CREATE TABLE IF NOT EXISTS karma_events (
193
+ id TEXT PRIMARY KEY,
194
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
195
+ action TEXT NOT NULL,
196
+ ref TEXT NOT NULL DEFAULT '',
197
+ points INTEGER NOT NULL,
198
+ day TEXT NOT NULL DEFAULT '',
199
+ dedupe TEXT NOT NULL UNIQUE,
200
+ created_at INTEGER NOT NULL
201
+ );
202
+ CREATE INDEX IF NOT EXISTS karma_user ON karma_events(user_id, created_at);
203
+ CREATE INDEX IF NOT EXISTS karma_time ON karma_events(created_at);
204
+ CREATE INDEX IF NOT EXISTS karma_daily ON karma_events(user_id, action, day);
205
+ CREATE INDEX IF NOT EXISTS karma_ref ON karma_events(ref);
206
+ -- A free Featured week for every KARMA_PER_CREDIT karma: one row per milestone crossed, never granted twice.
207
+ CREATE TABLE IF NOT EXISTS karma_credits (
208
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
209
+ milestone INTEGER NOT NULL,
210
+ granted_at INTEGER NOT NULL,
211
+ redeemed_product TEXT,
212
+ redeemed_at INTEGER,
213
+ PRIMARY KEY (user_id, milestone)
214
+ );
215
+ CREATE TABLE IF NOT EXISTS comments (
216
+ id TEXT PRIMARY KEY,
217
+ product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
218
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
219
+ parent_id TEXT,
220
+ body TEXT NOT NULL,
221
+ created_at INTEGER NOT NULL,
222
+ deleted_at INTEGER
223
+ );
224
+ CREATE INDEX IF NOT EXISTS comments_product ON comments(product_id, created_at);
225
+ CREATE TABLE IF NOT EXISTS reviews (
226
+ product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
227
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
228
+ rating INTEGER NOT NULL,
229
+ body TEXT NOT NULL DEFAULT '',
230
+ created_at INTEGER NOT NULL,
231
+ updated_at INTEGER NOT NULL,
232
+ PRIMARY KEY (product_id, user_id)
233
+ );
234
+ -- Outside sign-ins (Sign in with CoinPay): linked by the provider's subject, never by email.
235
+ CREATE TABLE IF NOT EXISTS user_identities (
236
+ provider TEXT NOT NULL,
237
+ subject TEXT NOT NULL,
238
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
239
+ email TEXT,
240
+ name TEXT,
241
+ created_at INTEGER NOT NULL,
242
+ PRIMARY KEY (provider, subject)
243
+ );
244
+ CREATE INDEX IF NOT EXISTS user_identities_user ON user_identities(user_id);
155
245
  CREATE TABLE IF NOT EXISTS payments (
156
246
  ref TEXT PRIMARY KEY,
157
247
  product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
@@ -163,6 +253,31 @@ export class Store {
163
253
  updated_at INTEGER NOT NULL
164
254
  );
165
255
  `)
256
+ // Columns added after launch: CREATE TABLE IF NOT EXISTS leaves an existing table as it was.
257
+ const has = (table: string, col: string) => (this.db.query(`PRAGMA table_info(${table})`).all() as { name: string }[]).some((c) => c.name === col)
258
+ if (!has('users', 'display_name')) this.db.exec('ALTER TABLE users ADD COLUMN display_name TEXT')
259
+ if (!has('magic_links', 'ref')) this.db.exec('ALTER TABLE magic_links ADD COLUMN ref TEXT')
260
+ // The signup form's profile fields ride on the link until it is spent (profile.ts saves them).
261
+ if (!has('magic_links', 'profile')) this.db.exec('ALTER TABLE magic_links ADD COLUMN profile TEXT')
262
+ // Hosted checkout sessions (0.5.0): the session is how CoinPay's webhook finds the row.
263
+ for (const col of ['checkout_session_id', 'customer_email', 'account_id', 'payment_id']) if (!has('payments', col)) this.db.exec(`ALTER TABLE payments ADD COLUMN ${col} TEXT`)
264
+ // Karma (0.6.0): who cast a vote, when they were signed in; review totals beside the vote count.
265
+ if (!has('votes', 'user_id')) this.db.exec('ALTER TABLE votes ADD COLUMN user_id TEXT')
266
+ for (const col of ['review_count', 'review_sum']) if (!has('products', col)) this.db.exec(`ALTER TABLE products ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 0`)
267
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS payments_session ON payments(checkout_session_id) WHERE checkout_session_id IS NOT NULL')
268
+ this.backfillTags()
269
+ }
270
+
271
+ /** Listings launched before tags were inferred get topics from their own words; tagged ones are left alone. */
272
+ backfillTags(): number {
273
+ const rows = this.db.query("SELECT id, name, tagline, description FROM products WHERE tags = '[]'").all() as { id: string; name: string; tagline: string; description: string }[]
274
+ const set = this.db.query('UPDATE products SET tags = ? WHERE id = ?')
275
+ let n = 0
276
+ for (const r of rows) {
277
+ const tags = inferTags([r.name, r.tagline, r.description].filter(Boolean).join('. '))
278
+ if (tags.length) set.run(JSON.stringify(tags), r.id), n++
279
+ }
280
+ return n
166
281
  }
167
282
 
168
283
  /** One visitor, per product, without keeping their address. */
@@ -202,6 +317,8 @@ export class Store {
202
317
  submitterUser: row.submitter_user,
203
318
  status: row.status,
204
319
  votes: row.votes,
320
+ reviews: row.review_count ?? 0,
321
+ rating: row.review_count ? Math.round((row.review_sum / row.review_count) * 10) / 10 : null,
205
322
  verifiedAt: row.verified_at,
206
323
  featuredUntil: row.featured_until,
207
324
  launchedAt: row.launched_at,
@@ -220,6 +337,24 @@ export class Store {
220
337
  return this.toProduct(this.db.query("SELECT * FROM products WHERE url_key = ? AND status != 'hidden' ORDER BY created_at LIMIT 1").get(Store.urlKey(url)))
221
338
  }
222
339
 
340
+ /** The live listing for a site's homepage (any page of it), for badges that only know the site: its homepage listing first, else any listing on that host. */
341
+ bySite(site: string): Product | null {
342
+ let host: string
343
+ try {
344
+ host = new URL(/^https?:\/\//i.test(site) ? site : `https://${site}`).hostname.replace(/^www\./, '').toLowerCase()
345
+ } catch {
346
+ return null
347
+ }
348
+ return this.toProduct(
349
+ this.db.query("SELECT * FROM products WHERE status = 'live' AND (url_key = ? OR domain = ? OR domain = ?) ORDER BY url_key = ? DESC, created_at LIMIT 1").get(host, host, `www.${host}`, host),
350
+ )
351
+ }
352
+
353
+ /** Live listings with no badge found yet: the re-verify sweep's work list. */
354
+ unverified(limit = 200): Product[] {
355
+ return (this.db.query("SELECT * FROM products WHERE status = 'live' AND verified_at IS NULL ORDER BY launched_at LIMIT ?").all(limit) as any[]).map((r) => this.toProduct(r)!)
356
+ }
357
+
223
358
  freeSlug(name: string): string {
224
359
  const base = slugify(name)
225
360
  let slug = base
@@ -227,7 +362,7 @@ export class Store {
227
362
  return slug
228
363
  }
229
364
 
230
- create(p: Omit<Product, 'votes' | 'verifiedAt' | 'featuredUntil' | 'launchedAt' | 'createdAt' | 'updatedAt' | 'makerId' | 'makerVerified' | 'ownerId' | 'claimedAt'>, submitter: string, now = Date.now()): Product {
365
+ create(p: Omit<Product, 'votes' | 'reviews' | 'rating' | 'verifiedAt' | 'featuredUntil' | 'launchedAt' | 'createdAt' | 'updatedAt' | 'makerId' | 'makerVerified' | 'ownerId' | 'claimedAt'>, submitter: string, now = Date.now()): Product {
231
366
  this.db
232
367
  .query(
233
368
  `INSERT INTO products (id, slug, url, url_key, domain, name, tagline, description, logo, og_image, screenshot, images, videos, socials, tags, theme_color, llms, profile, record, extras, maker_name, maker_profile, status, submitter_user, submitter, launched_at, created_at, updated_at)
@@ -247,6 +382,21 @@ export class Store {
247
382
  this.db.query('UPDATE products SET owner_id = ?, claimed_at = ?, updated_at = ? WHERE id = ?').run(userId, now, now, id)
248
383
  }
249
384
 
385
+ /**
386
+ * The badge id for this account on this listing: opaque, URL-safe, 16 chars, made
387
+ * once and handed back every time after, so a badge placed yesterday still counts.
388
+ */
389
+ badgeId(productId: string, userId: string, now = Date.now()): string {
390
+ this.db.query('INSERT OR IGNORE INTO badge_ids (id, product_id, user_id, created_at) VALUES (?, ?, ?, ?)').run(newId(12), productId, userId, now)
391
+ return (this.db.query('SELECT id FROM badge_ids WHERE product_id = ? AND user_id = ?').get(productId, userId) as { id: string }).id
392
+ }
393
+
394
+ /** Whose badge id this is: the listing and the account it was made for. */
395
+ badge(id: string): { productId: string; userId: string } | null {
396
+ const row = this.db.query('SELECT product_id, user_id FROM badge_ids WHERE id = ?').get(id) as { product_id: string; user_id: string } | null
397
+ return row ? { productId: row.product_id, userId: row.user_id } : null
398
+ }
399
+
250
400
  /** Everything a person owns or submitted, newest first. */
251
401
  mine(userId: string): Product[] {
252
402
  return (this.db.query("SELECT * FROM products WHERE (owner_id = ? OR (owner_id IS NULL AND submitter_user = ?)) AND status != 'hidden' ORDER BY created_at DESC").all(userId, userId) as any[]).map((r) => this.toProduct(r)!)
@@ -264,7 +414,215 @@ export class Store {
264
414
  }
265
415
 
266
416
  private toUser(r: any): User | null {
267
- return r ? { id: r.id, email: r.email, createdAt: r.created_at } : null
417
+ return r ? { id: r.id, email: r.email, createdAt: r.created_at, displayName: r.display_name ?? null } : null
418
+ }
419
+
420
+ userByEmail(email: string): User | null {
421
+ return this.toUser(this.db.query('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()))
422
+ }
423
+
424
+ setDisplayName(userId: string, name: string | null): void {
425
+ this.db.query('UPDATE users SET display_name = ? WHERE id = ?').run(name, userId)
426
+ }
427
+
428
+ /** What the public sees for a member: the name they chose, else their best listing, never the email. */
429
+ publicName(userId: string): string {
430
+ const u = this.user(userId)
431
+ if (u?.displayName) return u.displayName
432
+ const p = this.db.query("SELECT name FROM products WHERE (owner_id = ? OR (owner_id IS NULL AND submitter_user = ?)) AND status = 'live' ORDER BY votes DESC LIMIT 1").get(userId, userId) as { name: string } | null
433
+ return p ? `Maker of ${p.name}` : `Member ${userId.slice(0, 4)}`
434
+ }
435
+
436
+ // The invite program: a link per member; each new member it brings in who launches earns the inviter a free Featured week.
437
+
438
+ /** This member's invite code, made once. */
439
+ inviteCode(userId: string, now = Date.now()): string {
440
+ this.db.query('INSERT OR IGNORE INTO invite_codes (code, user_id, created_at) VALUES (?, ?, ?)').run(newId(6), userId, now)
441
+ return (this.db.query('SELECT code FROM invite_codes WHERE user_id = ?').get(userId) as { code: string }).code
442
+ }
443
+
444
+ inviterOf(code: string): string | null {
445
+ const r = this.db.query('SELECT user_id FROM invite_codes WHERE code = ?').get(String(code)) as { user_id: string } | null
446
+ return r?.user_id ?? null
447
+ }
448
+
449
+ /** A new member joined through `code`. Once per member, never yourself. True when it counted. */
450
+ recordInvite(inviteeId: string, code: string, now = Date.now()): boolean {
451
+ const inviter = this.inviterOf(code)
452
+ if (!inviter || inviter === inviteeId) return false
453
+ return this.db.query('INSERT OR IGNORE INTO invites (invitee_id, inviter_id, created_at) VALUES (?, ?, ?)').run(inviteeId, inviter, now).changes > 0
454
+ }
455
+
456
+ /** The invited member's listing went live: their inviter earns a credit, the first time only. Returns the inviter. */
457
+ earnInvite(inviteeId: string, productId: string, now = Date.now()): string | null {
458
+ const r = this.db.query('UPDATE invites SET product_id = ?, earned_at = ? WHERE invitee_id = ? AND earned_at IS NULL RETURNING inviter_id').get(productId, now, inviteeId) as { inviter_id: string } | null
459
+ return r?.inviter_id ?? null
460
+ }
461
+
462
+ /** A listing taken down for spam takes back the credit it earned, unless it was already spent. */
463
+ revokeInvite(productId: string): void {
464
+ this.db.query('UPDATE invites SET product_id = NULL, earned_at = NULL WHERE product_id = ? AND redeemed_at IS NULL').run(productId)
465
+ }
466
+
467
+ inviteStats(userId: string): InviteStats {
468
+ const r = this.db
469
+ .query('SELECT COUNT(*) AS invited, COUNT(earned_at) AS launched, COUNT(redeemed_at) AS used FROM invites WHERE inviter_id = ?')
470
+ .get(userId) as { invited: number; launched: number; used: number }
471
+ const k = this.db.query('SELECT COUNT(*) AS granted, COUNT(redeemed_at) AS used FROM karma_credits WHERE user_id = ?').get(userId) as { granted: number; used: number }
472
+ const karmaCredits = k.granted - k.used
473
+ return { code: this.inviteCode(userId), invited: r.invited, launched: r.launched, used: r.used + k.used, credits: r.launched - r.used + karmaCredits, karmaCredits }
474
+ }
475
+
476
+ /** Spend one earned credit: the product is featured for `days` more. False when there is no credit left. */
477
+ redeemCredit(userId: string, productId: string, days: number, now = Date.now()): boolean {
478
+ return this.db.transaction(() => {
479
+ const r = this.db
480
+ .query('UPDATE invites SET redeemed_product = ?, redeemed_at = ? WHERE invitee_id = (SELECT invitee_id FROM invites WHERE inviter_id = ? AND earned_at IS NOT NULL AND redeemed_at IS NULL ORDER BY earned_at LIMIT 1)')
481
+ .run(productId, now, userId)
482
+ // No invite credit left: a karma credit is the same free week.
483
+ if (!r.changes && !this.db.query('UPDATE karma_credits SET redeemed_product = ?, redeemed_at = ? WHERE rowid = (SELECT rowid FROM karma_credits WHERE user_id = ? AND redeemed_at IS NULL ORDER BY milestone LIMIT 1)').run(productId, now, userId).changes) return false
484
+ this.extendFeatured(productId, days, now)
485
+ return true
486
+ })()
487
+ }
488
+
489
+ /** Every credit earned since `since`, for the inviters' leaderboard. */
490
+ earnedInvites(since = 0): { inviter: string; at: number }[] {
491
+ return (this.db.query('SELECT inviter_id, earned_at FROM invites WHERE earned_at IS NOT NULL AND earned_at >= ?').all(since) as { inviter_id: string; earned_at: number }[]).map((r) => ({ inviter: r.inviter_id, at: r.earned_at }))
492
+ }
493
+
494
+ // Outside identities (Sign in with CoinPay).
495
+
496
+ /** The member an outside account is linked to. */
497
+ identityUser(provider: string, subject: string): User | null {
498
+ const r = this.db.query('SELECT user_id FROM user_identities WHERE provider = ? AND subject = ?').get(provider, subject) as { user_id: string } | null
499
+ return r ? this.user(r.user_id) : null
500
+ }
501
+
502
+ /** Link an outside account to a member. False when it already belongs to someone else. */
503
+ linkIdentity(i: { provider: string; subject: string; userId: string; email?: string | null; name?: string | null }, now = Date.now()): boolean {
504
+ const owner = this.identityUser(i.provider, i.subject)
505
+ if (owner && owner.id !== i.userId) return false
506
+ this.db.query('INSERT INTO user_identities (provider, subject, user_id, email, name, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(provider, subject) DO UPDATE SET email = excluded.email, name = excluded.name').run(i.provider, i.subject, i.userId, i.email ?? null, i.name ?? null, now)
507
+ return true
508
+ }
509
+
510
+ identities(userId: string): { provider: string; subject: string; email: string | null; name: string | null }[] {
511
+ return this.db.query('SELECT provider, subject, email, name FROM user_identities WHERE user_id = ? ORDER BY created_at').all(userId) as { provider: string; subject: string; email: string | null; name: string | null }[]
512
+ }
513
+
514
+ // Karma: the ledger. Rules (what pays, caps, self-dealing) live in karma.ts; this is storage.
515
+
516
+ /**
517
+ * Write one ledger row unless its dedupe key is taken, or a daily cap for the action
518
+ * is full. True when it was written. One transaction, so two clicks cannot both pass the cap.
519
+ */
520
+ addKarma(e: { userId: string; action: string; ref: string; points: number; dedupe: string; daily?: boolean; cap?: number }, now = Date.now()): boolean {
521
+ const day = e.daily || e.cap ? dayOf(now) : ''
522
+ return this.db.transaction(() => {
523
+ if (e.cap && (this.db.query('SELECT COUNT(*) AS n FROM karma_events WHERE user_id = ? AND action = ? AND day = ? AND points > 0').get(e.userId, e.action, day) as { n: number }).n >= e.cap) return false
524
+ return this.db.query('INSERT OR IGNORE INTO karma_events (id, user_id, action, ref, points, day, dedupe, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(newId(), e.userId, e.action, e.ref, e.points, day, e.dedupe, now).changes > 0
525
+ })()
526
+ }
527
+
528
+ /** Take back what one ledger row paid: a new negative row, once. The user it paid, or null when there was nothing to reverse. */
529
+ reverseKarma(dedupe: string, action: string, now = Date.now()): string | null {
530
+ const row = this.db.query('SELECT id, user_id, ref, points FROM karma_events WHERE dedupe = ? AND points > 0').get(dedupe) as { id: string; user_id: string; ref: string; points: number } | null
531
+ if (!row) return null
532
+ const r = this.db.query('INSERT OR IGNORE INTO karma_events (id, user_id, action, ref, points, day, dedupe, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(newId(), row.user_id, action, row.ref, -row.points, '', `reverse:${row.id}`, now)
533
+ return r.changes ? row.user_id : null
534
+ }
535
+
536
+ /** Every unreversed positive row about a listing (its id, or `<id>:…`): what a takedown takes back. */
537
+ karmaAbout(productId: string): string[] {
538
+ return (this.db.query("SELECT e.dedupe FROM karma_events e WHERE (e.ref = ? OR e.ref LIKE ? ESCAPE '\\') AND e.points > 0 AND NOT EXISTS (SELECT 1 FROM karma_events r WHERE r.dedupe = 'reverse:' || e.id)").all(productId, `${productId.replace(/[\\%_]/g, (c) => `\\${c}`)}:%`) as { dedupe: string }[]).map((r) => r.dedupe)
539
+ }
540
+
541
+ karmaTotal(userId: string, since = 0): number {
542
+ return (this.db.query('SELECT COALESCE(SUM(points), 0) AS n FROM karma_events WHERE user_id = ? AND created_at >= ?').get(userId, since) as { n: number }).n
543
+ }
544
+
545
+ /** Several members' all-time karma at once, for the names next to comments and reviews. */
546
+ karmaOf(userIds: string[]): Map<string, number> {
547
+ const ids = [...new Set(userIds)]
548
+ if (!ids.length) return new Map()
549
+ const rows = this.db.query(`SELECT user_id, COALESCE(SUM(points), 0) AS n FROM karma_events WHERE user_id IN (${ids.map(() => '?').join(',')}) GROUP BY user_id`).all(...ids) as { user_id: string; n: number }[]
550
+ return new Map(ids.map((id) => [id, rows.find((r) => r.user_id === id)?.n ?? 0]))
551
+ }
552
+
553
+ karmaEvents(userId: string, limit = 30): KarmaEvent[] {
554
+ return (this.db.query('SELECT * FROM karma_events WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?').all(userId, limit) as any[]).map((r) => ({ id: r.id, userId: r.user_id, action: r.action, ref: r.ref, points: r.points, createdAt: r.created_at }))
555
+ }
556
+
557
+ /** Every ledger row since `since`, for the karma leaderboard. */
558
+ karmaSince(since = 0): { userId: string; points: number; at: number }[] {
559
+ return (this.db.query('SELECT user_id, points, created_at FROM karma_events WHERE created_at >= ?').all(since) as { user_id: string; points: number; created_at: number }[]).map((r) => ({ userId: r.user_id, points: r.points, at: r.created_at }))
560
+ }
561
+
562
+ /** Grant a credit for every `per` karma milestone reached and not granted before. Returns how many were new. */
563
+ grantKarmaCredits(userId: string, per: number, now = Date.now()): number {
564
+ if (!(per > 0)) return 0
565
+ const reached = Math.floor(this.karmaTotal(userId) / per)
566
+ let n = 0
567
+ for (let m = 1; m <= reached; m++) n += this.db.query('INSERT OR IGNORE INTO karma_credits (user_id, milestone, granted_at) VALUES (?, ?, ?)').run(userId, m * per, now).changes
568
+ return n
569
+ }
570
+
571
+ karmaCredits(userId: string): { granted: number; used: number; best: number } {
572
+ return this.db.query('SELECT COUNT(*) AS granted, COUNT(redeemed_at) AS used, COALESCE(MAX(milestone), 0) AS best FROM karma_credits WHERE user_id = ?').get(userId) as { granted: number; used: number; best: number }
573
+ }
574
+
575
+ // Comments and reviews.
576
+
577
+ addComment(c: { productId: string; userId: string; parentId: string | null; body: string }, now = Date.now()): Comment {
578
+ const id = newId()
579
+ this.db.query('INSERT INTO comments (id, product_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, c.productId, c.userId, c.parentId, c.body, now)
580
+ return this.comment(id)!
581
+ }
582
+
583
+ comment(id: string): Comment | null {
584
+ const r: any = this.db.query('SELECT * FROM comments WHERE id = ?').get(id)
585
+ return r ? { id: r.id, productId: r.product_id, userId: r.user_id, parentId: r.parent_id, body: r.body, createdAt: r.created_at, deletedAt: r.deleted_at } : null
586
+ }
587
+
588
+ /** A listing's comments, oldest first; deleted ones only while a reply hangs off them. */
589
+ comments(productId: string): Comment[] {
590
+ return (this.db.query('SELECT * FROM comments c WHERE product_id = ? AND (deleted_at IS NULL OR EXISTS (SELECT 1 FROM comments r WHERE r.parent_id = c.id AND r.deleted_at IS NULL)) ORDER BY created_at, rowid').all(productId) as any[]).map((r) => ({ id: r.id, productId: r.product_id, userId: r.user_id, parentId: r.parent_id, body: r.deleted_at ? '' : r.body, createdAt: r.created_at, deletedAt: r.deleted_at }))
591
+ }
592
+
593
+ /** Soft delete: replies keep their place; the words go. */
594
+ deleteComment(id: string, now = Date.now()): boolean {
595
+ return this.db.query("UPDATE comments SET deleted_at = ?, body = '' WHERE id = ? AND deleted_at IS NULL").run(now, id).changes > 0
596
+ }
597
+
598
+ /** Write or replace a member's review; the listing's totals follow. */
599
+ putReview(r: { productId: string; userId: string; rating: number; body: string }, now = Date.now()): Review {
600
+ this.db.transaction(() => {
601
+ this.db.query('INSERT INTO reviews (product_id, user_id, rating, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(product_id, user_id) DO UPDATE SET rating = excluded.rating, body = excluded.body, updated_at = excluded.updated_at').run(r.productId, r.userId, r.rating, r.body, now, now)
602
+ this.reviewTotals(r.productId)
603
+ })()
604
+ return this.review(r.productId, r.userId)!
605
+ }
606
+
607
+ deleteReview(productId: string, userId: string): boolean {
608
+ return this.db.transaction(() => {
609
+ const n = this.db.query('DELETE FROM reviews WHERE product_id = ? AND user_id = ?').run(productId, userId).changes
610
+ this.reviewTotals(productId)
611
+ return n > 0
612
+ })()
613
+ }
614
+
615
+ private reviewTotals(productId: string): void {
616
+ this.db.query('UPDATE products SET review_count = (SELECT COUNT(*) FROM reviews WHERE product_id = ?), review_sum = (SELECT COALESCE(SUM(rating), 0) FROM reviews WHERE product_id = ?) WHERE id = ?').run(productId, productId, productId)
617
+ }
618
+
619
+ review(productId: string, userId: string): Review | null {
620
+ const r: any = this.db.query('SELECT * FROM reviews WHERE product_id = ? AND user_id = ?').get(productId, userId)
621
+ return r ? { productId: r.product_id, userId: r.user_id, rating: r.rating, body: r.body, createdAt: r.created_at, updatedAt: r.updated_at } : null
622
+ }
623
+
624
+ reviews(productId: string): Review[] {
625
+ return (this.db.query('SELECT * FROM reviews WHERE product_id = ? ORDER BY updated_at DESC').all(productId) as any[]).map((r) => ({ productId: r.product_id, userId: r.user_id, rating: r.rating, body: r.body, createdAt: r.created_at, updatedAt: r.updated_at }))
268
626
  }
269
627
 
270
628
  newApiKey(userId: string, now = Date.now()): string {
@@ -344,6 +702,24 @@ export class Store {
344
702
  return (this.db.query("SELECT * FROM products WHERE status = 'live' AND (name LIKE ? ESCAPE '\\' OR tagline LIKE ? ESCAPE '\\' OR domain LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\') ORDER BY votes DESC LIMIT ?").all(like, like, like, like, limit) as any[]).map((r) => this.toProduct(r)!)
345
703
  }
346
704
 
705
+ /** Live products with this exact tag, best first. */
706
+ byTag(tag: string, limit = 50): Product[] {
707
+ return (this.db.query("SELECT p.* FROM products p WHERE p.status = 'live' AND EXISTS (SELECT 1 FROM json_each(p.tags) WHERE value = ?) ORDER BY p.votes DESC, p.launched_at DESC LIMIT ?").all(tag, limit) as any[]).map((r) => this.toProduct(r)!)
708
+ }
709
+
710
+ /** Every tag on a live product, with how many carry it; most used first. */
711
+ tagCounts(limit = 200): { tag: string; count: number }[] {
712
+ return this.db.query("SELECT t.value AS tag, COUNT(*) AS count FROM products p, json_each(p.tags) t WHERE p.status = 'live' GROUP BY t.value ORDER BY count DESC, tag LIMIT ?").all(limit) as { tag: string; count: number }[]
713
+ }
714
+ /** Every live launch, for browse.ts to filter and count. Cached until something changes. */
715
+ allLive(): Product[] {
716
+ const stamp = this.db.query("SELECT COUNT(*) AS n, MAX(updated_at) AS u, SUM(votes) AS v, SUM(COALESCE(featured_until, 0)) AS f, COUNT(verified_at) AS c, SUM(review_count) AS rc, SUM(review_sum) AS rs FROM products WHERE status = 'live'").get() as Record<string, number | null>
717
+ const key = `${stamp.n}:${stamp.u}:${stamp.v}:${stamp.f}:${stamp.c}:${stamp.rc}:${stamp.rs}`
718
+ if (this.liveCache?.key !== key) this.liveCache = { key, products: (this.db.query("SELECT * FROM products WHERE status = 'live'").all() as any[]).map((r) => this.toProduct(r)!) }
719
+ return this.liveCache.products
720
+ }
721
+ private liveCache: { key: string; products: Product[] } | null = null
722
+
347
723
  count(): { products: number; votes: number } {
348
724
  return this.db.query("SELECT (SELECT COUNT(*) FROM products WHERE status = 'live') AS products, (SELECT COUNT(*) FROM votes) AS votes").get() as { products: number; votes: number }
349
725
  }
@@ -358,8 +734,8 @@ export class Store {
358
734
  }
359
735
 
360
736
  /** One vote per visitor per product. True when it counted. */
361
- vote(productId: string, voter: string, now = Date.now()): boolean {
362
- const r = this.db.query('INSERT OR IGNORE INTO votes (product_id, voter, created_at) VALUES (?, ?, ?)').run(productId, voter, now)
737
+ vote(productId: string, voter: string, now = Date.now(), userId: string | null = null): boolean {
738
+ const r = this.db.query('INSERT OR IGNORE INTO votes (product_id, voter, created_at, user_id) VALUES (?, ?, ?, ?)').run(productId, voter, now, userId)
363
739
  if (!r.changes) return false
364
740
  this.db.query('UPDATE products SET votes = votes + 1 WHERE id = ?').run(productId)
365
741
  return true
@@ -389,8 +765,24 @@ export class Store {
389
765
  return r ? { id: r.id, name: r.name, profileUrl: r.profile_url, headline: r.headline, avatar: r.avatar, web: r.web, accounts: JSON.parse(r.accounts), createdAt: r.created_at } : null
390
766
  }
391
767
 
392
- addPayment(p: { ref: string; productId: string; amountUsd: number; chain: string; days: number }, now = Date.now()): void {
393
- this.db.query('INSERT OR IGNORE INTO payments (ref, product_id, amount_usd, chain, days, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(p.ref, p.productId, p.amountUsd, p.chain, p.days, now, now)
768
+ addPayment(p: { ref: string; productId: string; amountUsd: number; chain: string; days: number; checkoutSessionId?: string | null; email?: string | null; accountId?: string | null }, now = Date.now()): void {
769
+ this.db
770
+ .query('INSERT OR IGNORE INTO payments (ref, product_id, amount_usd, chain, days, checkout_session_id, customer_email, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
771
+ .run(p.ref, p.productId, p.amountUsd, p.chain, p.days, p.checkoutSessionId ?? null, p.email ?? null, p.accountId ?? null, now, now)
772
+ }
773
+
774
+ paymentBySession(sessionId: string): { ref: string; productId: string; days: number; status: string } | null {
775
+ const r: any = this.db.query('SELECT ref, product_id, days, status FROM payments WHERE checkout_session_id = ?').get(sessionId)
776
+ return r ? { ref: r.ref, productId: r.product_id, days: r.days, status: r.status } : null
777
+ }
778
+
779
+ /** CoinPay's payment id and how it was paid (a coin, or paypal), once a webhook names them. */
780
+ notePaymentId(ref: string, paymentId: string, method: string | null, now = Date.now()): void {
781
+ this.db.query("UPDATE payments SET payment_id = ?, chain = CASE WHEN ? IS NOT NULL AND ? != '' THEN ? ELSE chain END, updated_at = ? WHERE ref = ?").run(paymentId, method, method, method, now, ref)
782
+ }
783
+
784
+ deletePayment(ref: string): void {
785
+ this.db.query('DELETE FROM payments WHERE ref = ?').run(ref)
394
786
  }
395
787
 
396
788
  payment(ref: string): { ref: string; productId: string; days: number; status: string } | null {
@@ -398,11 +790,16 @@ export class Store {
398
790
  return r ? { ref: r.ref, productId: r.product_id, days: r.days, status: r.status } : null
399
791
  }
400
792
 
793
+ /** The one way a listing gets Featured time: paid weeks, invite and karma credits, and redeemed Featured days. */
794
+ extendFeatured(productId: string, days: number, now = Date.now()): void {
795
+ this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, days * 86400_000, productId)
796
+ }
797
+
401
798
  /** Mark a payment settled and extend the product's featured time, exactly once per payment. */
402
799
  settle(ref: string, now = Date.now()): boolean {
403
800
  const r: any = this.db.query("UPDATE payments SET status = 'paid', updated_at = ? WHERE ref = ? AND status != 'paid' RETURNING product_id, days").get(now, ref)
404
801
  if (!r) return false
405
- this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, r.days * 86400_000, r.product_id)
802
+ this.extendFeatured(r.product_id, r.days, now)
406
803
  return true
407
804
  }
408
805