@meith/db 0.21.2 → 0.22.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/migrations/0058_mass_mail_opt_in.sql +2 -0
- package/migrations/meta/0058_snapshot.json +9509 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +30 -30
- package/src/attachment-admin-repo.ts +0 -1
- package/src/marketplace-repo.ts +0 -6
- package/src/member-settings-repo.ts +18 -1
- package/src/navigation-repo.ts +0 -9
- package/src/pglite.fixture.ts +0 -6
- package/src/plugin-purge-repo.ts +0 -8
- package/src/presence-repo.ts +0 -20
- package/src/schema/identity.ts +2 -0
- package/src/schema/platform.ts +0 -16
- package/src/user-admin-repo.ts +0 -1
- package/src/user-bulk-repo.ts +11 -0
|
@@ -407,6 +407,13 @@
|
|
|
407
407
|
"when": 1787650466674,
|
|
408
408
|
"tag": "0057_promotion_scan_cursor",
|
|
409
409
|
"breakpoints": true
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"idx": 58,
|
|
413
|
+
"version": "7",
|
|
414
|
+
"when": 1787771682008,
|
|
415
|
+
"tag": "0058_mass_mail_opt_in",
|
|
416
|
+
"breakpoints": true
|
|
410
417
|
}
|
|
411
418
|
]
|
|
412
419
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,35 +22,35 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"postgres": "^3.4.7",
|
|
25
|
-
"@meith/accounts": "0.
|
|
26
|
-
"@meith/
|
|
27
|
-
"@meith/
|
|
28
|
-
"@meith/
|
|
29
|
-
"@meith/
|
|
30
|
-
"@meith/api": "0.
|
|
31
|
-
"@meith/avatars": "0.
|
|
32
|
-
"@meith/
|
|
33
|
-
"@meith/
|
|
34
|
-
"@meith/events": "0.
|
|
35
|
-
"@meith/forums": "0.
|
|
36
|
-
"@meith/groups": "0.
|
|
37
|
-
"@meith/
|
|
38
|
-
"@meith/
|
|
39
|
-
"@meith/
|
|
40
|
-
"@meith/
|
|
41
|
-
"@meith/
|
|
42
|
-
"@meith/
|
|
43
|
-
"@meith/
|
|
44
|
-
"@meith/
|
|
45
|
-
"@meith/
|
|
46
|
-
"@meith/
|
|
47
|
-
"@meith/
|
|
48
|
-
"@meith/search": "0.
|
|
49
|
-
"@meith/
|
|
50
|
-
"@meith/
|
|
51
|
-
"@meith/
|
|
52
|
-
"@meith/
|
|
53
|
-
"@meith/
|
|
25
|
+
"@meith/accounts": "0.22.0",
|
|
26
|
+
"@meith/admin": "0.22.0",
|
|
27
|
+
"@meith/antispam": "0.22.0",
|
|
28
|
+
"@meith/authorization": "0.22.0",
|
|
29
|
+
"@meith/attachments": "0.22.0",
|
|
30
|
+
"@meith/api": "0.22.0",
|
|
31
|
+
"@meith/avatars": "0.22.0",
|
|
32
|
+
"@meith/core": "0.22.0",
|
|
33
|
+
"@meith/drafts": "0.22.0",
|
|
34
|
+
"@meith/events": "0.22.0",
|
|
35
|
+
"@meith/forums": "0.22.0",
|
|
36
|
+
"@meith/groups": "0.22.0",
|
|
37
|
+
"@meith/markdown": "0.22.0",
|
|
38
|
+
"@meith/i18n": "0.22.0",
|
|
39
|
+
"@meith/marketplace": "0.22.0",
|
|
40
|
+
"@meith/messages": "0.22.0",
|
|
41
|
+
"@meith/moderation": "0.22.0",
|
|
42
|
+
"@meith/plugin-kit": "0.22.0",
|
|
43
|
+
"@meith/polls": "0.22.0",
|
|
44
|
+
"@meith/notifications": "0.22.0",
|
|
45
|
+
"@meith/relations": "0.22.0",
|
|
46
|
+
"@meith/profile-fields": "0.22.0",
|
|
47
|
+
"@meith/settings": "0.22.0",
|
|
48
|
+
"@meith/search": "0.22.0",
|
|
49
|
+
"@meith/signatures": "0.22.0",
|
|
50
|
+
"@meith/threads": "0.22.0",
|
|
51
|
+
"@meith/tasks": "0.22.0",
|
|
52
|
+
"@meith/subscriptions": "0.22.0",
|
|
53
|
+
"@meith/reputation": "0.22.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"drizzle-kit": "^0.31.6",
|
|
@@ -31,7 +31,6 @@ export interface AttachmentAdminFilter {
|
|
|
31
31
|
export interface AttachmentAdminPage {
|
|
32
32
|
readonly rows: readonly AttachmentAdminRow[]
|
|
33
33
|
readonly nextBeforeId: number | null
|
|
34
|
-
/** How many attachments the filter matches, whichever page was asked for. */
|
|
35
34
|
readonly total: number
|
|
36
35
|
}
|
|
37
36
|
|
package/src/marketplace-repo.ts
CHANGED
|
@@ -32,12 +32,6 @@ function toCached(row: Row | undefined): CachedMarketplace {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* The single-row cache behind the Browse tab — see the `marketplace_catalog`
|
|
37
|
-
* table comment in `packages/db/src/schema/platform.ts` for why a table
|
|
38
|
-
* rather than the generic `settings` store: this holds a whole feed
|
|
39
|
-
* document and fetch bookkeeping, not a board-operator preference.
|
|
40
|
-
*/
|
|
41
35
|
export class PostgresMarketplaceCacheRepository implements MarketplaceCacheRepository {
|
|
42
36
|
constructor(private readonly db: Database) {}
|
|
43
37
|
|
|
@@ -18,6 +18,7 @@ interface RawSettings {
|
|
|
18
18
|
website: string | null
|
|
19
19
|
bio: string | null
|
|
20
20
|
display_group_id: number | null
|
|
21
|
+
mass_mail_opt_in_at: Date | string | null
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface RawGroupChoice {
|
|
@@ -34,7 +35,8 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
34
35
|
const rows = resultRows(
|
|
35
36
|
await this.db.execute(sql`
|
|
36
37
|
select id, email, timezone, locale, posts_per_page, threads_per_page,
|
|
37
|
-
invisible, location, website, bio, display_group_id
|
|
38
|
+
invisible, location, website, bio, display_group_id,
|
|
39
|
+
mass_mail_opt_in_at
|
|
38
40
|
from users
|
|
39
41
|
where id = ${userId} and state <> 'deleted'
|
|
40
42
|
`),
|
|
@@ -55,6 +57,7 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
55
57
|
website: row.website,
|
|
56
58
|
bio: row.bio,
|
|
57
59
|
displayGroupId: row.display_group_id === null ? null : Number(row.display_group_id),
|
|
60
|
+
massMailOptInAt: row.mass_mail_opt_in_at === null ? null : new Date(row.mass_mail_opt_in_at),
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
63
|
|
|
@@ -133,6 +136,20 @@ export class PostgresMemberSettingsRepository implements MemberSettingsRepositor
|
|
|
133
136
|
`)
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
async saveMassMailOptIn(input: {
|
|
140
|
+
readonly userId: number
|
|
141
|
+
readonly optIn: boolean
|
|
142
|
+
}): Promise<void> {
|
|
143
|
+
const consent = input.optIn ? sql`coalesce(mass_mail_opt_in_at, now())` : sql`null`
|
|
144
|
+
|
|
145
|
+
await this.db.execute(sql`
|
|
146
|
+
update users
|
|
147
|
+
set mass_mail_opt_in_at = ${consent},
|
|
148
|
+
updated_at = now()
|
|
149
|
+
where id = ${input.userId} and state <> 'deleted'
|
|
150
|
+
`)
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
async adoptEmail(input: {
|
|
137
154
|
readonly userId: number
|
|
138
155
|
readonly email: string
|
package/src/navigation-repo.ts
CHANGED
|
@@ -28,12 +28,10 @@ export interface NavigationItemRow {
|
|
|
28
28
|
readonly visibleToGroups: readonly number[]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** A navigation item a plugin declares, already namespaced by the host. */
|
|
32
31
|
export interface PluginNavigationRow {
|
|
33
32
|
readonly key: string
|
|
34
33
|
readonly href: string
|
|
35
34
|
readonly audience: NavigationAudience
|
|
36
|
-
/** The key of the sibling row this one is created under, if any. */
|
|
37
35
|
readonly parentKey: string | null
|
|
38
36
|
}
|
|
39
37
|
|
|
@@ -95,13 +93,6 @@ export class PostgresNavigationRepository {
|
|
|
95
93
|
return rows.map(toRow)
|
|
96
94
|
}
|
|
97
95
|
|
|
98
|
-
/**
|
|
99
|
-
* Bring the plugin-owned rows into line with what the installed plugins ask
|
|
100
|
-
* for. An item's row is created once and then belongs to the operator: its
|
|
101
|
-
* label, order, nesting, audience and visibility survive every redeploy,
|
|
102
|
-
* because only the address a plugin owns is refreshed here. A row whose
|
|
103
|
-
* plugin is gone goes with it.
|
|
104
|
-
*/
|
|
105
96
|
async syncPluginItems(declared: readonly PluginNavigationRow[]): Promise<{
|
|
106
97
|
readonly added: readonly string[]
|
|
107
98
|
readonly removed: readonly string[]
|
package/src/pglite.fixture.ts
CHANGED
|
@@ -17,16 +17,10 @@ export interface QueryLog {
|
|
|
17
17
|
export interface TestDb {
|
|
18
18
|
readonly db: Database
|
|
19
19
|
readonly queries: QueryLog
|
|
20
|
-
/**
|
|
21
|
-
* The engine underneath, for the few tests that need to send it something
|
|
22
|
-
* drizzle cannot: `exec()` takes a script of several statements, which is how
|
|
23
|
-
* the migration files arrive and what a prepared statement refuses.
|
|
24
|
-
*/
|
|
25
20
|
readonly client: PGlite
|
|
26
21
|
close(): Promise<void>
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
/** The checked-in migrations, as one script `client.exec()` will accept. */
|
|
30
24
|
export function migrationScript(): string {
|
|
31
25
|
return migrationSql().split('--> statement-breakpoint').join('\n')
|
|
32
26
|
}
|
package/src/plugin-purge-repo.ts
CHANGED
|
@@ -32,14 +32,6 @@ export async function pluginOwnedTables(
|
|
|
32
32
|
return rows.map((row) => String(row.table_name))
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Everything a plugin left behind, taken away in one transaction.
|
|
37
|
-
*
|
|
38
|
-
* The tables are found by prefix rather than from the plugin's own migration
|
|
39
|
-
* list, because the list says what it meant to create and the schema says what
|
|
40
|
-
* it did. The prefix is safe to trust: `definePlugin` refuses a migration that
|
|
41
|
-
* names anything outside it, so nothing the board owns can match.
|
|
42
|
-
*/
|
|
43
35
|
export async function purgePlugin(db: Database, pluginKey: string): Promise<PluginPurgeResult> {
|
|
44
36
|
const tables = await pluginOwnedTables(db, pluginKey)
|
|
45
37
|
|
package/src/presence-repo.ts
CHANGED
|
@@ -118,18 +118,6 @@ export class PostgresPresenceRepository {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
122
|
-
* Records that an anonymous reader is here, under a session row with no user.
|
|
123
|
-
*
|
|
124
|
-
* The read side has counted these rows since presence was written — the
|
|
125
|
-
* `user_id` column is nullable for exactly this — but nothing ever wrote one,
|
|
126
|
-
* so every board reported zero guests forever. This is the missing half.
|
|
127
|
-
*
|
|
128
|
-
* `windowSeconds` throttles the write the way the member path does: a reader
|
|
129
|
-
* clicking through a thread should cost one update a minute, not one an
|
|
130
|
-
* article. The insert is unconditional, though, or a reader whose first page
|
|
131
|
-
* view landed inside another reader's throttle window would never appear.
|
|
132
|
-
*/
|
|
133
121
|
async touchGuest(input: {
|
|
134
122
|
readonly tokenHash: string
|
|
135
123
|
readonly location: {
|
|
@@ -161,14 +149,6 @@ export class PostgresPresenceRepository {
|
|
|
161
149
|
`)
|
|
162
150
|
}
|
|
163
151
|
|
|
164
|
-
/**
|
|
165
|
-
* Forgets a guest row, for when the reader holding it has just signed in.
|
|
166
|
-
*
|
|
167
|
-
* Without this they are two people for the next fifteen minutes — a member in
|
|
168
|
-
* the list and a guest in the count — because the row they browsed under is
|
|
169
|
-
* still inside the window and there is nothing tying it to the account they
|
|
170
|
-
* have now proved they own.
|
|
171
|
-
*/
|
|
172
152
|
async dropGuest(tokenHash: string): Promise<void> {
|
|
173
153
|
await this.db.execute(sql`
|
|
174
154
|
delete from sessions where token_hash = ${tokenHash} and user_id is null
|
package/src/schema/identity.ts
CHANGED
|
@@ -110,6 +110,7 @@ export const users = pgTable(
|
|
|
110
110
|
|
|
111
111
|
lastActiveAt: timestamp('last_active_at', { withTimezone: true }),
|
|
112
112
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
|
113
|
+
massMailOptInAt: timestamp('mass_mail_opt_in_at', { withTimezone: true }),
|
|
113
114
|
|
|
114
115
|
suspendedPostingUntil: timestamp('suspended_posting_until', { withTimezone: true }),
|
|
115
116
|
moderatedPostingUntil: timestamp('moderated_posting_until', { withTimezone: true }),
|
|
@@ -141,6 +142,7 @@ export const users = pgTable(
|
|
|
141
142
|
index('users_signature_render_version_idx')
|
|
142
143
|
.on(t.signatureRenderVersion, t.id)
|
|
143
144
|
.where(sql`${t.signature} <> ''`),
|
|
145
|
+
index('users_mass_mail_opt_in_idx').on(t.id).where(sql`${t.massMailOptInAt} is not null`),
|
|
144
146
|
],
|
|
145
147
|
)
|
|
146
148
|
|
package/src/schema/platform.ts
CHANGED
|
@@ -160,27 +160,11 @@ export const pluginHealth = pgTable('plugin_health', {
|
|
|
160
160
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
-
/**
|
|
164
|
-
* The cached copy of the meith.dev marketplace feed (or a self-hosted
|
|
165
|
-
* mirror's) — a single row, refreshed daily by a task and on demand by the
|
|
166
|
-
* admin panel's "Refresh" button. `feed` and `fetchedAt` are null until the
|
|
167
|
-
* first successful fetch; `error`/`errorAt` describe the most recent failed
|
|
168
|
-
* attempt, cleared on the next success, so a board with no outbound network
|
|
169
|
-
* shows a plain "unreachable" note rather than nothing. `notifiedUpdates`
|
|
170
|
-
* carries `"<plugin key>@<version>"` strings, one per update notification
|
|
171
|
-
* ever sent, which is what makes that notification fire once per version —
|
|
172
|
-
* not once per unread bell entry, which the notification service's own
|
|
173
|
-
* dedupe key gives up on as soon as the entry is read.
|
|
174
|
-
*/
|
|
175
163
|
export const marketplaceCatalog = pgTable(
|
|
176
164
|
'marketplace_catalog',
|
|
177
165
|
{
|
|
178
166
|
id: smallint('id').primaryKey().default(1),
|
|
179
167
|
feed: jsonb('feed'),
|
|
180
|
-
/** The feed URL the cached copy was actually fetched from — screenshots are
|
|
181
|
-
* absolute *paths*, resolved against this rather than against whatever the
|
|
182
|
-
* setting reads today, so a mid-day setting change cannot point an old
|
|
183
|
-
* cached screenshot at the wrong host. */
|
|
184
168
|
sourceUrl: text('source_url'),
|
|
185
169
|
fetchedAt: timestamp('fetched_at', { withTimezone: true }),
|
|
186
170
|
error: text('error'),
|
package/src/user-admin-repo.ts
CHANGED
|
@@ -54,7 +54,6 @@ export interface UserSearchRow {
|
|
|
54
54
|
export interface UserSearchPage {
|
|
55
55
|
readonly rows: readonly UserSearchRow[]
|
|
56
56
|
readonly nextCursor: number | null
|
|
57
|
-
/** How many accounts the filter matches, whichever page was asked for. */
|
|
58
57
|
readonly total: number
|
|
59
58
|
}
|
|
60
59
|
|
package/src/user-bulk-repo.ts
CHANGED
|
@@ -296,6 +296,16 @@ export class PostgresUserBulkRepository {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
async mayReceiveMassMail(userId: number): Promise<boolean> {
|
|
300
|
+
const rows = resultRows(
|
|
301
|
+
await this.db.execute(sql`
|
|
302
|
+
select 1 as ok from users u
|
|
303
|
+
where u.id = ${userId} and ${this.audienceWhere(null)}
|
|
304
|
+
`),
|
|
305
|
+
) as Array<{ ok: number }>
|
|
306
|
+
return rows.length > 0
|
|
307
|
+
}
|
|
308
|
+
|
|
299
309
|
async massMailAudience(targetGroupId: number | null): Promise<number> {
|
|
300
310
|
const rows = resultRows(
|
|
301
311
|
await this.db.execute(sql`
|
|
@@ -332,6 +342,7 @@ export class PostgresUserBulkRepository {
|
|
|
332
342
|
sql`u.deleted_at is null`,
|
|
333
343
|
sql`u.state = 'active'`,
|
|
334
344
|
sql`u.email_verified_at is not null`,
|
|
345
|
+
sql`u.mass_mail_opt_in_at is not null`,
|
|
335
346
|
]
|
|
336
347
|
|
|
337
348
|
if (targetGroupId !== null) {
|