@profullstack/builtindie 0.1.4 → 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/OpenStack.md +1 -1
- package/README.md +24 -4
- package/package.json +5 -2
- package/public/app.js +58 -0
- package/public/style.css +108 -11
- package/src/auth.ts +10 -6
- package/src/browse.ts +243 -0
- package/src/cli.ts +110 -10
- package/src/client.ts +81 -5
- package/src/coinpay-signin.ts +118 -0
- package/src/db.ts +398 -8
- package/src/fetch.ts +15 -7
- package/src/html.ts +490 -76
- package/src/karma.ts +179 -0
- package/src/leaderboard.ts +59 -0
- package/src/mcp.ts +52 -4
- package/src/pit-footer.ts +39 -0
- package/src/ring-footer.ts +53 -0
- package/src/ring.ts +5 -1
- package/src/server.ts +439 -42
- package/src/service.ts +238 -74
- package/src/tags.ts +92 -0
- package/src/tui.ts +28 -6
- package/src/version.ts +1 -1
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
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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,
|