@profullstack/builtindie 0.1.3 → 0.6.0

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.
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,29 @@ 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
+ // Hosted checkout sessions (0.5.0): the session is how CoinPay's webhook finds the row.
261
+ 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`)
262
+ // Karma (0.6.0): who cast a vote, when they were signed in; review totals beside the vote count.
263
+ if (!has('votes', 'user_id')) this.db.exec('ALTER TABLE votes ADD COLUMN user_id TEXT')
264
+ 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`)
265
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS payments_session ON payments(checkout_session_id) WHERE checkout_session_id IS NOT NULL')
266
+ this.backfillTags()
267
+ }
268
+
269
+ /** Listings launched before tags were inferred get topics from their own words; tagged ones are left alone. */
270
+ backfillTags(): number {
271
+ const rows = this.db.query("SELECT id, name, tagline, description FROM products WHERE tags = '[]'").all() as { id: string; name: string; tagline: string; description: string }[]
272
+ const set = this.db.query('UPDATE products SET tags = ? WHERE id = ?')
273
+ let n = 0
274
+ for (const r of rows) {
275
+ const tags = inferTags([r.name, r.tagline, r.description].filter(Boolean).join('. '))
276
+ if (tags.length) set.run(JSON.stringify(tags), r.id), n++
277
+ }
278
+ return n
166
279
  }
167
280
 
168
281
  /** One visitor, per product, without keeping their address. */
@@ -202,6 +315,8 @@ export class Store {
202
315
  submitterUser: row.submitter_user,
203
316
  status: row.status,
204
317
  votes: row.votes,
318
+ reviews: row.review_count ?? 0,
319
+ rating: row.review_count ? Math.round((row.review_sum / row.review_count) * 10) / 10 : null,
205
320
  verifiedAt: row.verified_at,
206
321
  featuredUntil: row.featured_until,
207
322
  launchedAt: row.launched_at,
@@ -220,6 +335,24 @@ export class Store {
220
335
  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
336
  }
222
337
 
338
+ /** 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. */
339
+ bySite(site: string): Product | null {
340
+ let host: string
341
+ try {
342
+ host = new URL(/^https?:\/\//i.test(site) ? site : `https://${site}`).hostname.replace(/^www\./, '').toLowerCase()
343
+ } catch {
344
+ return null
345
+ }
346
+ return this.toProduct(
347
+ 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),
348
+ )
349
+ }
350
+
351
+ /** Live listings with no badge found yet: the re-verify sweep's work list. */
352
+ unverified(limit = 200): Product[] {
353
+ 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)!)
354
+ }
355
+
223
356
  freeSlug(name: string): string {
224
357
  const base = slugify(name)
225
358
  let slug = base
@@ -227,7 +360,7 @@ export class Store {
227
360
  return slug
228
361
  }
229
362
 
230
- create(p: Omit<Product, 'votes' | 'verifiedAt' | 'featuredUntil' | 'launchedAt' | 'createdAt' | 'updatedAt' | 'makerId' | 'makerVerified' | 'ownerId' | 'claimedAt'>, submitter: string, now = Date.now()): Product {
363
+ create(p: Omit<Product, 'votes' | 'reviews' | 'rating' | 'verifiedAt' | 'featuredUntil' | 'launchedAt' | 'createdAt' | 'updatedAt' | 'makerId' | 'makerVerified' | 'ownerId' | 'claimedAt'>, submitter: string, now = Date.now()): Product {
231
364
  this.db
232
365
  .query(
233
366
  `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 +380,21 @@ export class Store {
247
380
  this.db.query('UPDATE products SET owner_id = ?, claimed_at = ?, updated_at = ? WHERE id = ?').run(userId, now, now, id)
248
381
  }
249
382
 
383
+ /**
384
+ * The badge id for this account on this listing: opaque, URL-safe, 16 chars, made
385
+ * once and handed back every time after, so a badge placed yesterday still counts.
386
+ */
387
+ badgeId(productId: string, userId: string, now = Date.now()): string {
388
+ this.db.query('INSERT OR IGNORE INTO badge_ids (id, product_id, user_id, created_at) VALUES (?, ?, ?, ?)').run(newId(12), productId, userId, now)
389
+ return (this.db.query('SELECT id FROM badge_ids WHERE product_id = ? AND user_id = ?').get(productId, userId) as { id: string }).id
390
+ }
391
+
392
+ /** Whose badge id this is: the listing and the account it was made for. */
393
+ badge(id: string): { productId: string; userId: string } | null {
394
+ 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
395
+ return row ? { productId: row.product_id, userId: row.user_id } : null
396
+ }
397
+
250
398
  /** Everything a person owns or submitted, newest first. */
251
399
  mine(userId: string): Product[] {
252
400
  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 +412,215 @@ export class Store {
264
412
  }
265
413
 
266
414
  private toUser(r: any): User | null {
267
- return r ? { id: r.id, email: r.email, createdAt: r.created_at } : null
415
+ return r ? { id: r.id, email: r.email, createdAt: r.created_at, displayName: r.display_name ?? null } : null
416
+ }
417
+
418
+ userByEmail(email: string): User | null {
419
+ return this.toUser(this.db.query('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()))
420
+ }
421
+
422
+ setDisplayName(userId: string, name: string | null): void {
423
+ this.db.query('UPDATE users SET display_name = ? WHERE id = ?').run(name, userId)
424
+ }
425
+
426
+ /** What the public sees for a member: the name they chose, else their best listing, never the email. */
427
+ publicName(userId: string): string {
428
+ const u = this.user(userId)
429
+ if (u?.displayName) return u.displayName
430
+ 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
431
+ return p ? `Maker of ${p.name}` : `Member ${userId.slice(0, 4)}`
432
+ }
433
+
434
+ // The invite program: a link per member; each new member it brings in who launches earns the inviter a free Featured week.
435
+
436
+ /** This member's invite code, made once. */
437
+ inviteCode(userId: string, now = Date.now()): string {
438
+ this.db.query('INSERT OR IGNORE INTO invite_codes (code, user_id, created_at) VALUES (?, ?, ?)').run(newId(6), userId, now)
439
+ return (this.db.query('SELECT code FROM invite_codes WHERE user_id = ?').get(userId) as { code: string }).code
440
+ }
441
+
442
+ inviterOf(code: string): string | null {
443
+ const r = this.db.query('SELECT user_id FROM invite_codes WHERE code = ?').get(String(code)) as { user_id: string } | null
444
+ return r?.user_id ?? null
445
+ }
446
+
447
+ /** A new member joined through `code`. Once per member, never yourself. True when it counted. */
448
+ recordInvite(inviteeId: string, code: string, now = Date.now()): boolean {
449
+ const inviter = this.inviterOf(code)
450
+ if (!inviter || inviter === inviteeId) return false
451
+ return this.db.query('INSERT OR IGNORE INTO invites (invitee_id, inviter_id, created_at) VALUES (?, ?, ?)').run(inviteeId, inviter, now).changes > 0
452
+ }
453
+
454
+ /** The invited member's listing went live: their inviter earns a credit, the first time only. Returns the inviter. */
455
+ earnInvite(inviteeId: string, productId: string, now = Date.now()): string | null {
456
+ 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
457
+ return r?.inviter_id ?? null
458
+ }
459
+
460
+ /** A listing taken down for spam takes back the credit it earned, unless it was already spent. */
461
+ revokeInvite(productId: string): void {
462
+ this.db.query('UPDATE invites SET product_id = NULL, earned_at = NULL WHERE product_id = ? AND redeemed_at IS NULL').run(productId)
463
+ }
464
+
465
+ inviteStats(userId: string): InviteStats {
466
+ const r = this.db
467
+ .query('SELECT COUNT(*) AS invited, COUNT(earned_at) AS launched, COUNT(redeemed_at) AS used FROM invites WHERE inviter_id = ?')
468
+ .get(userId) as { invited: number; launched: number; used: number }
469
+ 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 }
470
+ const karmaCredits = k.granted - k.used
471
+ return { code: this.inviteCode(userId), invited: r.invited, launched: r.launched, used: r.used + k.used, credits: r.launched - r.used + karmaCredits, karmaCredits }
472
+ }
473
+
474
+ /** Spend one earned credit: the product is featured for `days` more. False when there is no credit left. */
475
+ redeemCredit(userId: string, productId: string, days: number, now = Date.now()): boolean {
476
+ return this.db.transaction(() => {
477
+ const r = this.db
478
+ .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)')
479
+ .run(productId, now, userId)
480
+ // No invite credit left: a karma credit is the same free week.
481
+ 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
482
+ this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, days * 86400_000, productId)
483
+ return true
484
+ })()
485
+ }
486
+
487
+ /** Every credit earned since `since`, for the inviters' leaderboard. */
488
+ earnedInvites(since = 0): { inviter: string; at: number }[] {
489
+ 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 }))
490
+ }
491
+
492
+ // Outside identities (Sign in with CoinPay).
493
+
494
+ /** The member an outside account is linked to. */
495
+ identityUser(provider: string, subject: string): User | null {
496
+ const r = this.db.query('SELECT user_id FROM user_identities WHERE provider = ? AND subject = ?').get(provider, subject) as { user_id: string } | null
497
+ return r ? this.user(r.user_id) : null
498
+ }
499
+
500
+ /** Link an outside account to a member. False when it already belongs to someone else. */
501
+ linkIdentity(i: { provider: string; subject: string; userId: string; email?: string | null; name?: string | null }, now = Date.now()): boolean {
502
+ const owner = this.identityUser(i.provider, i.subject)
503
+ if (owner && owner.id !== i.userId) return false
504
+ 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)
505
+ return true
506
+ }
507
+
508
+ identities(userId: string): { provider: string; subject: string; email: string | null; name: string | null }[] {
509
+ 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 }[]
510
+ }
511
+
512
+ // Karma: the ledger. Rules (what pays, caps, self-dealing) live in karma.ts; this is storage.
513
+
514
+ /**
515
+ * Write one ledger row unless its dedupe key is taken, or a daily cap for the action
516
+ * is full. True when it was written. One transaction, so two clicks cannot both pass the cap.
517
+ */
518
+ addKarma(e: { userId: string; action: string; ref: string; points: number; dedupe: string; daily?: boolean; cap?: number }, now = Date.now()): boolean {
519
+ const day = e.daily || e.cap ? dayOf(now) : ''
520
+ return this.db.transaction(() => {
521
+ 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
522
+ 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
523
+ })()
524
+ }
525
+
526
+ /** Take back what one ledger row paid: a new negative row, once. The user it paid, or null when there was nothing to reverse. */
527
+ reverseKarma(dedupe: string, action: string, now = Date.now()): string | null {
528
+ 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
529
+ if (!row) return null
530
+ 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)
531
+ return r.changes ? row.user_id : null
532
+ }
533
+
534
+ /** Every unreversed positive row about a listing (its id, or `<id>:…`): what a takedown takes back. */
535
+ karmaAbout(productId: string): string[] {
536
+ 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)
537
+ }
538
+
539
+ karmaTotal(userId: string, since = 0): number {
540
+ 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
541
+ }
542
+
543
+ /** Several members' all-time karma at once, for the names next to comments and reviews. */
544
+ karmaOf(userIds: string[]): Map<string, number> {
545
+ const ids = [...new Set(userIds)]
546
+ if (!ids.length) return new Map()
547
+ 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 }[]
548
+ return new Map(ids.map((id) => [id, rows.find((r) => r.user_id === id)?.n ?? 0]))
549
+ }
550
+
551
+ karmaEvents(userId: string, limit = 30): KarmaEvent[] {
552
+ 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 }))
553
+ }
554
+
555
+ /** Every ledger row since `since`, for the karma leaderboard. */
556
+ karmaSince(since = 0): { userId: string; points: number; at: number }[] {
557
+ 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 }))
558
+ }
559
+
560
+ /** Grant a credit for every `per` karma milestone reached and not granted before. Returns how many were new. */
561
+ grantKarmaCredits(userId: string, per: number, now = Date.now()): number {
562
+ if (!(per > 0)) return 0
563
+ const reached = Math.floor(this.karmaTotal(userId) / per)
564
+ let n = 0
565
+ 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
566
+ return n
567
+ }
568
+
569
+ karmaCredits(userId: string): { granted: number; used: number; best: number } {
570
+ 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 }
571
+ }
572
+
573
+ // Comments and reviews.
574
+
575
+ addComment(c: { productId: string; userId: string; parentId: string | null; body: string }, now = Date.now()): Comment {
576
+ const id = newId()
577
+ 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)
578
+ return this.comment(id)!
579
+ }
580
+
581
+ comment(id: string): Comment | null {
582
+ const r: any = this.db.query('SELECT * FROM comments WHERE id = ?').get(id)
583
+ 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
584
+ }
585
+
586
+ /** A listing's comments, oldest first; deleted ones only while a reply hangs off them. */
587
+ comments(productId: string): Comment[] {
588
+ 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 }))
589
+ }
590
+
591
+ /** Soft delete: replies keep their place; the words go. */
592
+ deleteComment(id: string, now = Date.now()): boolean {
593
+ return this.db.query("UPDATE comments SET deleted_at = ?, body = '' WHERE id = ? AND deleted_at IS NULL").run(now, id).changes > 0
594
+ }
595
+
596
+ /** Write or replace a member's review; the listing's totals follow. */
597
+ putReview(r: { productId: string; userId: string; rating: number; body: string }, now = Date.now()): Review {
598
+ this.db.transaction(() => {
599
+ 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)
600
+ this.reviewTotals(r.productId)
601
+ })()
602
+ return this.review(r.productId, r.userId)!
603
+ }
604
+
605
+ deleteReview(productId: string, userId: string): boolean {
606
+ return this.db.transaction(() => {
607
+ const n = this.db.query('DELETE FROM reviews WHERE product_id = ? AND user_id = ?').run(productId, userId).changes
608
+ this.reviewTotals(productId)
609
+ return n > 0
610
+ })()
611
+ }
612
+
613
+ private reviewTotals(productId: string): void {
614
+ 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)
615
+ }
616
+
617
+ review(productId: string, userId: string): Review | null {
618
+ const r: any = this.db.query('SELECT * FROM reviews WHERE product_id = ? AND user_id = ?').get(productId, userId)
619
+ 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
620
+ }
621
+
622
+ reviews(productId: string): Review[] {
623
+ 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
624
  }
269
625
 
270
626
  newApiKey(userId: string, now = Date.now()): string {
@@ -344,6 +700,24 @@ export class Store {
344
700
  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
701
  }
346
702
 
703
+ /** Live products with this exact tag, best first. */
704
+ byTag(tag: string, limit = 50): Product[] {
705
+ 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)!)
706
+ }
707
+
708
+ /** Every tag on a live product, with how many carry it; most used first. */
709
+ tagCounts(limit = 200): { tag: string; count: number }[] {
710
+ 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 }[]
711
+ }
712
+ /** Every live launch, for browse.ts to filter and count. Cached until something changes. */
713
+ allLive(): Product[] {
714
+ 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>
715
+ const key = `${stamp.n}:${stamp.u}:${stamp.v}:${stamp.f}:${stamp.c}:${stamp.rc}:${stamp.rs}`
716
+ 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)!) }
717
+ return this.liveCache.products
718
+ }
719
+ private liveCache: { key: string; products: Product[] } | null = null
720
+
347
721
  count(): { products: number; votes: number } {
348
722
  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
723
  }
@@ -358,8 +732,8 @@ export class Store {
358
732
  }
359
733
 
360
734
  /** 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)
735
+ vote(productId: string, voter: string, now = Date.now(), userId: string | null = null): boolean {
736
+ const r = this.db.query('INSERT OR IGNORE INTO votes (product_id, voter, created_at, user_id) VALUES (?, ?, ?, ?)').run(productId, voter, now, userId)
363
737
  if (!r.changes) return false
364
738
  this.db.query('UPDATE products SET votes = votes + 1 WHERE id = ?').run(productId)
365
739
  return true
@@ -389,8 +763,24 @@ export class Store {
389
763
  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
764
  }
391
765
 
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)
766
+ 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 {
767
+ this.db
768
+ .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
769
+ .run(p.ref, p.productId, p.amountUsd, p.chain, p.days, p.checkoutSessionId ?? null, p.email ?? null, p.accountId ?? null, now, now)
770
+ }
771
+
772
+ paymentBySession(sessionId: string): { ref: string; productId: string; days: number; status: string } | null {
773
+ const r: any = this.db.query('SELECT ref, product_id, days, status FROM payments WHERE checkout_session_id = ?').get(sessionId)
774
+ return r ? { ref: r.ref, productId: r.product_id, days: r.days, status: r.status } : null
775
+ }
776
+
777
+ /** CoinPay's payment id and how it was paid (a coin, or paypal), once a webhook names them. */
778
+ notePaymentId(ref: string, paymentId: string, method: string | null, now = Date.now()): void {
779
+ 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)
780
+ }
781
+
782
+ deletePayment(ref: string): void {
783
+ this.db.query('DELETE FROM payments WHERE ref = ?').run(ref)
394
784
  }
395
785
 
396
786
  payment(ref: string): { ref: string; productId: string; days: number; status: string } | null {
package/src/fetch.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { lookup } from 'node:dns/promises'
18
18
  import { isIP } from 'node:net'
19
19
  import { accounts, identityValue, kindOf as kindOfProfile, parseOpenProfile, web } from '@profullstack/openprofile'
20
+ import { tagsFor } from './tags.js'
20
21
 
21
22
  export type Video = { kind: 'youtube' | 'vimeo' | 'loom' | 'file'; url: string; embed: string | null }
22
23
 
@@ -554,13 +555,20 @@ export async function autofetch(input: string, o: FetchOptions = {}): Promise<Si
554
555
  for (const a of profile?.accounts ?? []) addSocial(a)
555
556
  for (const l of facts.links) addSocial(l)
556
557
 
557
- const tags = [
558
- ...new Set(
559
- [...String(m['keywords'] ?? '').split(','), ...asArray(app?.applicationCategory).map(String), ...asArray(manifest?.categories).map(String)]
560
- .map((t) => t.toLowerCase().replace(/application$/, '').replace(/[^a-z0-9 +#.-]/g, '').trim())
561
- .filter((t) => t.length >= 2 && t.length <= 24),
562
- ),
563
- ].slice(0, 6)
558
+ // Tags: what the site declares (opensite.json, keywords meta, article:tag, JSON-LD keywords,
559
+ // category and genre, manifest categories); else topics read from its own words.
560
+ const words = (v: unknown) => asArray(v).flatMap((k) => String(k ?? '').split(','))
561
+ const tags = tagsFor(
562
+ [
563
+ ...words(declared?.tags),
564
+ ...words(declared?.keywords),
565
+ ...words(m['keywords']),
566
+ ...words(m['article:tag']),
567
+ ...[app, org, site].flatMap((d) => [...words(d?.keywords), ...words(d?.applicationCategory), ...words(d?.genre)]),
568
+ ...words(manifest?.categories),
569
+ ],
570
+ { name, tagline, description },
571
+ )
564
572
 
565
573
  return {
566
574
  url: base.href,