@profullstack/builtindie 0.0.0-stage → 0.1.2

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/OpenStack.md +74 -0
  3. package/README.md +63 -2
  4. package/bin/builtindie +3 -0
  5. package/package.json +35 -4
  6. package/public/app.js +35 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icon-192.png +0 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icons/openicon.json +574 -0
  11. package/public/icons/svg/apple.svg +1 -0
  12. package/public/icons/svg/arrow-up.svg +1 -0
  13. package/public/icons/svg/bluesky.svg +1 -0
  14. package/public/icons/svg/check.svg +1 -0
  15. package/public/icons/svg/chrome.svg +1 -0
  16. package/public/icons/svg/code.svg +1 -0
  17. package/public/icons/svg/discord.svg +1 -0
  18. package/public/icons/svg/dollar.svg +1 -0
  19. package/public/icons/svg/external-link.svg +1 -0
  20. package/public/icons/svg/github.svg +1 -0
  21. package/public/icons/svg/globe.svg +1 -0
  22. package/public/icons/svg/instagram.svg +1 -0
  23. package/public/icons/svg/layers.svg +1 -0
  24. package/public/icons/svg/link.svg +1 -0
  25. package/public/icons/svg/linkedin.svg +1 -0
  26. package/public/icons/svg/mail.svg +1 -0
  27. package/public/icons/svg/product-hunt.svg +1 -0
  28. package/public/icons/svg/reddit.svg +1 -0
  29. package/public/icons/svg/rocket.svg +1 -0
  30. package/public/icons/svg/search.svg +1 -0
  31. package/public/icons/svg/share.svg +1 -0
  32. package/public/icons/svg/star.svg +1 -0
  33. package/public/icons/svg/tag.svg +1 -0
  34. package/public/icons/svg/telegram.svg +1 -0
  35. package/public/icons/svg/terminal.svg +1 -0
  36. package/public/icons/svg/tiktok.svg +1 -0
  37. package/public/icons/svg/trophy.svg +1 -0
  38. package/public/icons/svg/user.svg +1 -0
  39. package/public/icons/svg/x.svg +1 -0
  40. package/public/icons/svg/youtube.svg +1 -0
  41. package/public/install.sh +44 -0
  42. package/public/manifest.webmanifest +20 -0
  43. package/public/moshpit-root-ca.crt +11 -0
  44. package/public/style.css +162 -0
  45. package/public/sw.js +30 -0
  46. package/src/auth.ts +96 -0
  47. package/src/cli.ts +197 -0
  48. package/src/client.ts +115 -0
  49. package/src/db.ts +412 -0
  50. package/src/fetch.ts +708 -0
  51. package/src/html.ts +475 -0
  52. package/src/icons.ts +56 -0
  53. package/src/mcp.ts +79 -0
  54. package/src/media.ts +160 -0
  55. package/src/ring.ts +86 -0
  56. package/src/server.ts +647 -0
  57. package/src/service.ts +369 -0
  58. package/src/tui.ts +127 -0
  59. package/src/version.ts +1 -0
package/src/client.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The built.indie API from anywhere: the CLI, the TUI and the MCP server use this.
3
+ * BUILTINDIE_URL points it at another instance; BUILTINDIE_KEY (bi_…) is your
4
+ * account's API key (built.indie/me), for claiming and editing your products.
5
+ */
6
+ export type ApiProduct = {
7
+ slug: string
8
+ name: string
9
+ tagline: string
10
+ description: string
11
+ url: string
12
+ domain: string
13
+ status: string
14
+ votes: number
15
+ tags: string[]
16
+ logo: string | null
17
+ card_image: string | null
18
+ screenshot: string | null
19
+ images: string[]
20
+ videos: { kind: string; url: string; embed: string | null }[]
21
+ socials: Record<string, string>
22
+ llms: { url: string; title: string; summary: string } | null
23
+ profile: { url: string; kind: string | null; name: string; headline: string } | null
24
+ maker: { name: string; profile: string | null; verified: boolean; page: string | null } | null
25
+ verified: boolean
26
+ featured_until: string | null
27
+ launched_at: string | null
28
+ page: string
29
+ badge: string
30
+ opensite: string
31
+ openprofile: string
32
+ award?: { day: string; rank: number } | null
33
+ }
34
+
35
+ export type Launched = { product: ApiProduct; claim_url?: string; existing?: boolean }
36
+ export type ClaimRecord = { type: string; name: string; value: string }
37
+
38
+ export class ApiError extends Error {
39
+ constructor(message: string, readonly status: number) {
40
+ super(message)
41
+ }
42
+ }
43
+
44
+ export class BuiltIndieClient {
45
+ readonly base: string
46
+ constructor(base = process.env.BUILTINDIE_URL ?? 'https://built.indie.pit.world', private readonly key = process.env.BUILTINDIE_KEY, private readonly doFetch: typeof fetch = fetch) {
47
+ this.base = base.replace(/\/+$/, '')
48
+ }
49
+
50
+ private async call<T>(method: string, path: string, body?: unknown, key = this.key): Promise<T> {
51
+ const response = await this.doFetch(this.base + path, {
52
+ method,
53
+ headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}) },
54
+ ...(body ? { body: JSON.stringify(body) } : {}),
55
+ signal: AbortSignal.timeout(90_000),
56
+ })
57
+ const data = (await response.json().catch(() => ({}))) as { error?: string }
58
+ if (!response.ok) throw new ApiError(data.error ?? `built.indie answered ${response.status}`, response.status)
59
+ return data as T
60
+ }
61
+
62
+ launch(url: string, o: { makerName?: string; makerProfile?: string; name?: string; tagline?: string; description?: string; tags?: string[]; videos?: string[]; publish?: boolean } = {}): Promise<Launched> {
63
+ return this.call('POST', '/api/v1/products', { url, maker_name: o.makerName, maker_profile: o.makerProfile, name: o.name, tagline: o.tagline, description: o.description, tags: o.tags, videos: o.videos, publish: o.publish ?? true })
64
+ }
65
+
66
+ preview(url: string): Promise<Record<string, unknown>> {
67
+ return this.call('POST', '/api/v1/preview', { url })
68
+ }
69
+
70
+ list(window = 'today', limit = 30): Promise<{ window: string; products: ApiProduct[] }> {
71
+ return this.call('GET', `/api/v1/products?window=${encodeURIComponent(window)}&limit=${limit}`)
72
+ }
73
+
74
+ search(q: string): Promise<{ products: ApiProduct[] }> {
75
+ return this.call('GET', `/api/v1/products?q=${encodeURIComponent(q)}`)
76
+ }
77
+
78
+ product(slug: string): Promise<ApiProduct> {
79
+ return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}`)
80
+ }
81
+
82
+ vote(slug: string): Promise<{ slug: string; votes: number; counted: boolean }> {
83
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/vote`, {})
84
+ }
85
+
86
+ private needKey(key?: string): string {
87
+ const k = key ?? this.key
88
+ if (!k) throw new ApiError('Set BUILTINDIE_KEY to your API key: sign in at built.indie.pit.world/me and make one', 401)
89
+ return k
90
+ }
91
+
92
+ claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord }> {
93
+ return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/claim`, undefined, this.needKey())
94
+ }
95
+
96
+ claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord }> {
97
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/claim`, {}, this.needKey())
98
+ }
99
+
100
+ me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[] }> {
101
+ return this.call('GET', '/api/v1/me', undefined, this.needKey())
102
+ }
103
+
104
+ edit(slug: string, patch: Record<string, unknown>, key?: string): Promise<ApiProduct> {
105
+ return this.call('PATCH', `/api/v1/products/${encodeURIComponent(slug)}`, patch, this.needKey(key))
106
+ }
107
+
108
+ act(slug: string, action: 'publish' | 'refetch', key?: string): Promise<ApiProduct> {
109
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/${action}`, {}, this.needKey(key))
110
+ }
111
+
112
+ verify(slug: string): Promise<{ verified: boolean }> {
113
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/verify`, {})
114
+ }
115
+ }
package/src/db.ts ADDED
@@ -0,0 +1,412 @@
1
+ /**
2
+ * built.indie's state: one SQLite file (pictures live beside it, see media.ts).
3
+ *
4
+ * A product is a URL someone launched. Anyone may launch any URL, signed in or
5
+ * not: it is free. Who may change it afterwards:
6
+ * - its owner: whoever claimed it by putting our TXT record in the domain's DNS;
7
+ * - until then, the signed-in person who submitted it.
8
+ * An account is an email address (magic link, auth.ts). A vote is one per
9
+ * visitor per product, the visitor being a keyed hash of their address.
10
+ */
11
+ import { Database } from 'bun:sqlite'
12
+ import { createHash, createHmac, randomBytes } from 'node:crypto'
13
+ import type { Pricing, Profile, Stack, Video } from './fetch.js'
14
+
15
+ /** What a product says about itself in the other LogicSRC files: OpenStack.md, OpenSaaS, OpenIcon. */
16
+ export type Extras = { stack: Stack | null; pricing: Pricing | null; openicon: string | null }
17
+
18
+ export type Status = 'draft' | 'live' | 'hidden'
19
+
20
+ export type Product = {
21
+ id: string
22
+ slug: string
23
+ url: string
24
+ domain: string
25
+ name: string
26
+ tagline: string
27
+ description: string
28
+ logo: string | null
29
+ ogImage: string | null
30
+ screenshot: string | null
31
+ images: string[]
32
+ videos: Video[]
33
+ socials: Record<string, string>
34
+ tags: string[]
35
+ themeColor: string | null
36
+ llms: { url: string; title: string; summary: string } | null
37
+ profile: Profile | null
38
+ record: Record<string, unknown> | null
39
+ extras: Extras
40
+ makerName: string
41
+ makerProfile: string | null
42
+ makerId: string | null
43
+ makerVerified: boolean
44
+ ownerId: string | null
45
+ claimedAt: number | null
46
+ submitterUser: string | null
47
+ status: Status
48
+ votes: number
49
+ verifiedAt: number | null
50
+ featuredUntil: number | null
51
+ launchedAt: number | null
52
+ createdAt: number
53
+ updatedAt: number
54
+ }
55
+
56
+ export type User = { id: string; email: string; createdAt: number }
57
+
58
+ export type Maker = { id: string; name: string; profileUrl: string; headline: string; avatar: string | null; web: string | null; accounts: string[]; createdAt: number }
59
+
60
+ export type Window = 'today' | 'week' | 'month' | 'all' | 'new' | 'featured'
61
+
62
+ export const hashKey = (key: string) => createHash('sha256').update(key).digest('hex')
63
+ export const newId = (bytes = 9) => randomBytes(bytes).toString('base64url')
64
+ export const makerIdFor = (profileUrl: string) => createHash('sha256').update(profileUrl.toLowerCase()).digest('base64url').slice(0, 10)
65
+
66
+ /** 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'])
68
+
69
+ export function slugify(name: string): string {
70
+ const s = name
71
+ .normalize('NFKD')
72
+ .replace(/[̀-ͯ]/g, '')
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, '-')
75
+ .replace(/^-+|-+$/g, '')
76
+ .slice(0, 48)
77
+ return s.length >= 2 && !RESERVED.has(s) ? s : `product-${s || newId(3).toLowerCase()}`
78
+ }
79
+
80
+ /** The day a timestamp falls on, UTC, as 2026-10-19. Launch days are UTC days. */
81
+ export const dayOf = (ms: number) => new Date(ms).toISOString().slice(0, 10)
82
+ const startOfDay = (ms: number) => Date.parse(`${dayOf(ms)}T00:00:00Z`)
83
+
84
+ export class Store {
85
+ readonly db: Database
86
+ constructor(path: string, private readonly secret: string) {
87
+ if (!secret || secret.length < 16) throw new Error('BUILTINDIE_KEY must be set (32 random bytes, hex)')
88
+ this.db = new Database(path, { create: true })
89
+ this.db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;')
90
+ this.db.exec(`
91
+ CREATE TABLE IF NOT EXISTS products (
92
+ id TEXT PRIMARY KEY,
93
+ slug TEXT NOT NULL UNIQUE,
94
+ url TEXT NOT NULL,
95
+ url_key TEXT NOT NULL,
96
+ domain TEXT NOT NULL,
97
+ name TEXT NOT NULL,
98
+ tagline TEXT NOT NULL DEFAULT '',
99
+ description TEXT NOT NULL DEFAULT '',
100
+ logo TEXT,
101
+ og_image TEXT,
102
+ screenshot TEXT,
103
+ images TEXT NOT NULL DEFAULT '[]',
104
+ videos TEXT NOT NULL DEFAULT '[]',
105
+ socials TEXT NOT NULL DEFAULT '{}',
106
+ tags TEXT NOT NULL DEFAULT '[]',
107
+ theme_color TEXT,
108
+ llms TEXT,
109
+ profile TEXT,
110
+ record TEXT,
111
+ extras TEXT,
112
+ maker_name TEXT NOT NULL DEFAULT '',
113
+ maker_profile TEXT,
114
+ maker_id TEXT,
115
+ maker_verified INTEGER NOT NULL DEFAULT 0,
116
+ status TEXT NOT NULL DEFAULT 'draft',
117
+ votes INTEGER NOT NULL DEFAULT 0,
118
+ owner_id TEXT,
119
+ claimed_at INTEGER,
120
+ submitter_user TEXT,
121
+ submitter TEXT NOT NULL DEFAULT '',
122
+ verified_at INTEGER,
123
+ featured_until INTEGER,
124
+ launched_at INTEGER,
125
+ created_at INTEGER NOT NULL,
126
+ updated_at INTEGER NOT NULL
127
+ );
128
+ CREATE INDEX IF NOT EXISTS products_launched ON products(status, launched_at DESC);
129
+ CREATE INDEX IF NOT EXISTS products_url ON products(url_key);
130
+ CREATE INDEX IF NOT EXISTS products_maker ON products(maker_id);
131
+ CREATE INDEX IF NOT EXISTS products_owner ON products(owner_id);
132
+ CREATE INDEX IF NOT EXISTS products_submitter ON products(submitter_user);
133
+ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL);
134
+ CREATE TABLE IF NOT EXISTS sessions (id_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
135
+ CREATE TABLE IF NOT EXISTS magic_links (token_hash TEXT PRIMARY KEY, email TEXT NOT NULL, next TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
136
+ CREATE INDEX IF NOT EXISTS magic_links_email ON magic_links(email, created_at);
137
+ CREATE TABLE IF NOT EXISTS api_keys (key_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL);
138
+ CREATE TABLE IF NOT EXISTS votes (
139
+ product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
140
+ voter TEXT NOT NULL,
141
+ created_at INTEGER NOT NULL,
142
+ PRIMARY KEY (product_id, voter)
143
+ );
144
+ CREATE INDEX IF NOT EXISTS votes_time ON votes(created_at);
145
+ CREATE TABLE IF NOT EXISTS makers (
146
+ id TEXT PRIMARY KEY,
147
+ name TEXT NOT NULL,
148
+ profile_url TEXT NOT NULL UNIQUE,
149
+ headline TEXT NOT NULL DEFAULT '',
150
+ avatar TEXT,
151
+ web TEXT,
152
+ accounts TEXT NOT NULL DEFAULT '[]',
153
+ created_at INTEGER NOT NULL
154
+ );
155
+ CREATE TABLE IF NOT EXISTS payments (
156
+ ref TEXT PRIMARY KEY,
157
+ product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
158
+ amount_usd REAL NOT NULL,
159
+ chain TEXT NOT NULL,
160
+ days INTEGER NOT NULL,
161
+ status TEXT NOT NULL DEFAULT 'pending',
162
+ created_at INTEGER NOT NULL,
163
+ updated_at INTEGER NOT NULL
164
+ );
165
+ `)
166
+ }
167
+
168
+ /** One visitor, per product, without keeping their address. */
169
+ voterFor(ip: string): string {
170
+ return createHmac('sha256', this.secret).update(`voter:${ip}`).digest('base64url').slice(0, 22)
171
+ }
172
+
173
+ private toProduct(row: any): Product | null {
174
+ if (!row) return null
175
+ const j = (v: string | null, d: unknown) => (v ? JSON.parse(v) : d)
176
+ return {
177
+ id: row.id,
178
+ slug: row.slug,
179
+ url: row.url,
180
+ domain: row.domain,
181
+ name: row.name,
182
+ tagline: row.tagline,
183
+ description: row.description,
184
+ logo: row.logo,
185
+ ogImage: row.og_image,
186
+ screenshot: row.screenshot,
187
+ images: j(row.images, []),
188
+ videos: j(row.videos, []),
189
+ socials: j(row.socials, {}),
190
+ tags: j(row.tags, []),
191
+ themeColor: row.theme_color,
192
+ llms: j(row.llms, null),
193
+ profile: j(row.profile, null),
194
+ record: j(row.record, null),
195
+ extras: j(row.extras, { stack: null, pricing: null, openicon: null }),
196
+ makerName: row.maker_name,
197
+ makerProfile: row.maker_profile,
198
+ makerId: row.maker_id,
199
+ makerVerified: Boolean(row.maker_verified),
200
+ ownerId: row.owner_id,
201
+ claimedAt: row.claimed_at,
202
+ submitterUser: row.submitter_user,
203
+ status: row.status,
204
+ votes: row.votes,
205
+ verifiedAt: row.verified_at,
206
+ featuredUntil: row.featured_until,
207
+ launchedAt: row.launched_at,
208
+ createdAt: row.created_at,
209
+ updatedAt: row.updated_at,
210
+ }
211
+ }
212
+
213
+ /** The same product typed two ways: no scheme, no www, no trailing slash, no query. */
214
+ static urlKey(url: string): string {
215
+ const u = new URL(url)
216
+ return `${u.hostname.replace(/^www\./, '')}${u.pathname.replace(/\/+$/, '')}`.toLowerCase()
217
+ }
218
+
219
+ byUrl(url: string): Product | null {
220
+ 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
+ }
222
+
223
+ freeSlug(name: string): string {
224
+ const base = slugify(name)
225
+ let slug = base
226
+ for (let n = 2; this.db.query('SELECT 1 FROM products WHERE slug = ?').get(slug); n++) slug = `${base}-${n}`
227
+ return slug
228
+ }
229
+
230
+ create(p: Omit<Product, 'votes' | 'verifiedAt' | 'featuredUntil' | 'launchedAt' | 'createdAt' | 'updatedAt' | 'makerId' | 'makerVerified' | 'ownerId' | 'claimedAt'>, submitter: string, now = Date.now()): Product {
231
+ this.db
232
+ .query(
233
+ `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)
234
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
235
+ )
236
+ .run(p.id, p.slug, p.url, Store.urlKey(p.url), p.domain, p.name, p.tagline, p.description, p.logo, p.ogImage, p.screenshot, JSON.stringify(p.images), JSON.stringify(p.videos), JSON.stringify(p.socials), JSON.stringify(p.tags), p.themeColor, p.llms ? JSON.stringify(p.llms) : null, p.profile ? JSON.stringify(p.profile) : null, p.record ? JSON.stringify(p.record) : null, JSON.stringify(p.extras), p.makerName, p.makerProfile, p.status, p.submitterUser, submitter, p.status === 'live' ? now : null, now, now)
237
+ return this.product(p.id)!
238
+ }
239
+
240
+ /** The owner, or (until someone claims it) the signed-in person who submitted it. */
241
+ canEdit(p: Product, user: User | null): boolean {
242
+ if (!user || p.status === 'hidden') return false
243
+ return p.ownerId ? p.ownerId === user.id : p.submitterUser === user.id
244
+ }
245
+
246
+ claim(id: string, userId: string, now = Date.now()): void {
247
+ this.db.query('UPDATE products SET owner_id = ?, claimed_at = ?, updated_at = ? WHERE id = ?').run(userId, now, now, id)
248
+ }
249
+
250
+ /** Everything a person owns or submitted, newest first. */
251
+ mine(userId: string): Product[] {
252
+ 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)!)
253
+ }
254
+
255
+ // Accounts: an email address, sessions, magic links, API keys. Secrets are kept as SHA-256 only.
256
+
257
+ findOrCreateUser(email: string, now = Date.now()): User {
258
+ this.db.query('INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?, ?, ?)').run(newId(), email.toLowerCase(), now)
259
+ return this.toUser(this.db.query('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()))!
260
+ }
261
+
262
+ user(id: string): User | null {
263
+ return this.toUser(this.db.query('SELECT * FROM users WHERE id = ?').get(id))
264
+ }
265
+
266
+ private toUser(r: any): User | null {
267
+ return r ? { id: r.id, email: r.email, createdAt: r.created_at } : null
268
+ }
269
+
270
+ newApiKey(userId: string, now = Date.now()): string {
271
+ const key = `bi_${randomBytes(24).toString('base64url')}`
272
+ this.db.query('DELETE FROM api_keys WHERE user_id = ?').run(userId)
273
+ this.db.query('INSERT INTO api_keys (key_hash, user_id, created_at) VALUES (?, ?, ?)').run(hashKey(key), userId, now)
274
+ return key
275
+ }
276
+
277
+ byApiKey(key: string): User | null {
278
+ if (!key.startsWith('bi_')) return null
279
+ const r: any = this.db.query('SELECT user_id FROM api_keys WHERE key_hash = ?').get(hashKey(key))
280
+ return r ? this.user(r.user_id) : null
281
+ }
282
+
283
+ /** Change what the maker may change. Only known columns; JSON for the lists. */
284
+ update(id: string, patch: Partial<Pick<Product, 'name' | 'tagline' | 'description' | 'logo' | 'ogImage' | 'screenshot' | 'images' | 'videos' | 'socials' | 'tags' | 'themeColor' | 'llms' | 'profile' | 'record' | 'extras' | 'makerName' | 'makerProfile' | 'makerId' | 'makerVerified' | 'status' | 'url' | 'domain'>>, now = Date.now()): Product | null {
285
+ const cols: Record<string, string> = { name: 'name', tagline: 'tagline', description: 'description', logo: 'logo', ogImage: 'og_image', screenshot: 'screenshot', images: 'images', videos: 'videos', socials: 'socials', tags: 'tags', themeColor: 'theme_color', llms: 'llms', profile: 'profile', record: 'record', extras: 'extras', makerName: 'maker_name', makerProfile: 'maker_profile', makerId: 'maker_id', makerVerified: 'maker_verified', status: 'status', url: 'url', domain: 'domain' }
286
+ const sets: string[] = []
287
+ const values: (string | number | null)[] = []
288
+ for (const [k, v] of Object.entries(patch)) {
289
+ const col = cols[k]
290
+ if (!col || v === undefined) continue
291
+ sets.push(`${col} = ?`)
292
+ values.push(v === null ? null : typeof v === 'object' ? JSON.stringify(v) : typeof v === 'boolean' ? Number(v) : v)
293
+ if (k === 'url') sets.push('url_key = ?'), values.push(Store.urlKey(String(v)))
294
+ }
295
+ if (patch.status === 'live') sets.push('launched_at = COALESCE(launched_at, ?)'), values.push(now)
296
+ if (!sets.length) return this.product(id)
297
+ sets.push('updated_at = ?')
298
+ values.push(now, id)
299
+ this.db.query(`UPDATE products SET ${sets.join(', ')} WHERE id = ?`).run(...values)
300
+ return this.product(id)
301
+ }
302
+
303
+ product(id: string): Product | null {
304
+ return this.toProduct(this.db.query('SELECT * FROM products WHERE id = ?').get(id))
305
+ }
306
+
307
+ bySlug(slug: string): Product | null {
308
+ return this.toProduct(this.db.query('SELECT * FROM products WHERE slug = ?').get(slug.toLowerCase()))
309
+ }
310
+
311
+ delete(id: string): void {
312
+ this.db.query('DELETE FROM products WHERE id = ?').run(id)
313
+ }
314
+
315
+ /** Drafts nobody published within a day; returns their ids so the pictures go too. */
316
+ sweepDrafts(now = Date.now()): string[] {
317
+ return (this.db.query("DELETE FROM products WHERE status = 'draft' AND created_at < ? RETURNING id").all(now - 86400_000) as { id: string }[]).map((r) => r.id)
318
+ }
319
+
320
+ submissionsFrom(submitter: string, sinceMs: number): number {
321
+ return (this.db.query('SELECT COUNT(*) AS n FROM products WHERE submitter = ? AND created_at > ?').get(submitter, sinceMs) as { n: number }).n
322
+ }
323
+
324
+ /** Live products for a window, best first. `today` etc. are UTC launch days; `new` is newest first. */
325
+ list(window: Window = 'today', limit = 30, offset = 0, now = Date.now()): Product[] {
326
+ const day = 86400_000
327
+ const since = { today: startOfDay(now), week: now - 7 * day, month: now - 30 * day, all: 0, new: 0, featured: 0 }[window]
328
+ const where = window === 'featured' ? "status = 'live' AND featured_until > ?" : "status = 'live' AND launched_at >= ?"
329
+ const order = window === 'new' ? 'launched_at DESC' : 'votes DESC, launched_at ASC'
330
+ return (this.db.query(`SELECT * FROM products WHERE ${where} ORDER BY ${order} LIMIT ? OFFSET ?`).all(window === 'featured' ? now : since, limit, offset) as any[]).map((r) => this.toProduct(r)!)
331
+ }
332
+
333
+ onDay(day: string, limit = 50): Product[] {
334
+ const from = Date.parse(`${day}T00:00:00Z`)
335
+ return (this.db.query("SELECT * FROM products WHERE status = 'live' AND launched_at >= ? AND launched_at < ? ORDER BY votes DESC, launched_at ASC LIMIT ?").all(from, from + 86400_000, limit) as any[]).map((r) => this.toProduct(r)!)
336
+ }
337
+
338
+ byMaker(makerId: string): Product[] {
339
+ return (this.db.query("SELECT * FROM products WHERE maker_id = ? AND status = 'live' ORDER BY launched_at DESC").all(makerId) as any[]).map((r) => this.toProduct(r)!)
340
+ }
341
+
342
+ search(q: string, limit = 30): Product[] {
343
+ const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
344
+ 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
+ }
346
+
347
+ count(): { products: number; votes: number } {
348
+ 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
+ }
350
+
351
+ /** #1 of its launch day, once that day is over. */
352
+ awards(productId: string, now = Date.now()): { day: string; rank: number } | null {
353
+ const p = this.product(productId)
354
+ if (!p?.launchedAt || startOfDay(p.launchedAt) >= startOfDay(now)) return null
355
+ const day = dayOf(p.launchedAt)
356
+ const rank = this.onDay(day, 3).findIndex((x) => x.id === productId)
357
+ return rank >= 0 ? { day, rank: rank + 1 } : null
358
+ }
359
+
360
+ /** 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)
363
+ if (!r.changes) return false
364
+ this.db.query('UPDATE products SET votes = votes + 1 WHERE id = ?').run(productId)
365
+ return true
366
+ }
367
+
368
+ voted(productId: string, voter: string): boolean {
369
+ return Boolean(this.db.query('SELECT 1 FROM votes WHERE product_id = ? AND voter = ?').get(productId, voter))
370
+ }
371
+
372
+ setVerified(id: string, at: number | null): void {
373
+ this.db.query('UPDATE products SET verified_at = ? WHERE id = ?').run(at, id)
374
+ }
375
+
376
+ upsertMaker(m: Omit<Maker, 'id' | 'createdAt'>, now = Date.now()): Maker {
377
+ const id = makerIdFor(m.profileUrl)
378
+ this.db
379
+ .query(
380
+ `INSERT INTO makers (id, name, profile_url, headline, avatar, web, accounts, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
381
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, headline = excluded.headline, avatar = excluded.avatar, web = excluded.web, accounts = excluded.accounts`,
382
+ )
383
+ .run(id, m.name, m.profileUrl, m.headline, m.avatar, m.web, JSON.stringify(m.accounts), now)
384
+ return this.maker(id)!
385
+ }
386
+
387
+ maker(id: string): Maker | null {
388
+ const r: any = this.db.query('SELECT * FROM makers WHERE id = ?').get(id)
389
+ 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
+ }
391
+
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)
394
+ }
395
+
396
+ payment(ref: string): { ref: string; productId: string; days: number; status: string } | null {
397
+ const r: any = this.db.query('SELECT ref, product_id, days, status FROM payments WHERE ref = ?').get(ref)
398
+ return r ? { ref: r.ref, productId: r.product_id, days: r.days, status: r.status } : null
399
+ }
400
+
401
+ /** Mark a payment settled and extend the product's featured time, exactly once per payment. */
402
+ settle(ref: string, now = Date.now()): boolean {
403
+ 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
+ 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)
406
+ return true
407
+ }
408
+
409
+ setPaymentStatus(ref: string, status: string, now = Date.now()): void {
410
+ this.db.query("UPDATE payments SET status = ?, updated_at = ? WHERE ref = ? AND status != 'paid'").run(status, now, ref)
411
+ }
412
+ }