@meith/db 0.21.2 → 0.23.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.
@@ -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.21.2",
3
+ "version": "0.23.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.21.2",
26
- "@meith/antispam": "0.21.2",
27
- "@meith/attachments": "0.21.2",
28
- "@meith/admin": "0.21.2",
29
- "@meith/authorization": "0.21.2",
30
- "@meith/api": "0.21.2",
31
- "@meith/avatars": "0.21.2",
32
- "@meith/drafts": "0.21.2",
33
- "@meith/core": "0.21.2",
34
- "@meith/events": "0.21.2",
35
- "@meith/forums": "0.21.2",
36
- "@meith/groups": "0.21.2",
37
- "@meith/messages": "0.21.2",
38
- "@meith/marketplace": "0.21.2",
39
- "@meith/moderation": "0.21.2",
40
- "@meith/markdown": "0.21.2",
41
- "@meith/notifications": "0.21.2",
42
- "@meith/polls": "0.21.2",
43
- "@meith/plugin-kit": "0.21.2",
44
- "@meith/profile-fields": "0.21.2",
45
- "@meith/i18n": "0.21.2",
46
- "@meith/relations": "0.21.2",
47
- "@meith/reputation": "0.21.2",
48
- "@meith/search": "0.21.2",
49
- "@meith/settings": "0.21.2",
50
- "@meith/signatures": "0.21.2",
51
- "@meith/threads": "0.21.2",
52
- "@meith/tasks": "0.21.2",
53
- "@meith/subscriptions": "0.21.2"
25
+ "@meith/accounts": "0.23.0",
26
+ "@meith/admin": "0.23.0",
27
+ "@meith/antispam": "0.23.0",
28
+ "@meith/api": "0.23.0",
29
+ "@meith/attachments": "0.23.0",
30
+ "@meith/authorization": "0.23.0",
31
+ "@meith/avatars": "0.23.0",
32
+ "@meith/core": "0.23.0",
33
+ "@meith/drafts": "0.23.0",
34
+ "@meith/events": "0.23.0",
35
+ "@meith/forums": "0.23.0",
36
+ "@meith/groups": "0.23.0",
37
+ "@meith/i18n": "0.23.0",
38
+ "@meith/markdown": "0.23.0",
39
+ "@meith/marketplace": "0.23.0",
40
+ "@meith/messages": "0.23.0",
41
+ "@meith/moderation": "0.23.0",
42
+ "@meith/notifications": "0.23.0",
43
+ "@meith/plugin-kit": "0.23.0",
44
+ "@meith/polls": "0.23.0",
45
+ "@meith/profile-fields": "0.23.0",
46
+ "@meith/relations": "0.23.0",
47
+ "@meith/reputation": "0.23.0",
48
+ "@meith/search": "0.23.0",
49
+ "@meith/settings": "0.23.0",
50
+ "@meith/signatures": "0.23.0",
51
+ "@meith/subscriptions": "0.23.0",
52
+ "@meith/tasks": "0.23.0",
53
+ "@meith/threads": "0.23.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
 
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { BanFilterAdminRepository } from '@meith/accounts'
4
+ import { BAN_FILTER_PATTERN_MAX, BAN_FILTER_WILDCARD_MAX } from '@meith/accounts'
5
+ import { ValidationError } from '@meith/core'
6
+
7
+ export const CONTRACT_AUTHOR = 4242
8
+
9
+ export type BanFilterRepositoryFactory = () => Promise<BanFilterAdminRepository>
10
+
11
+ export function banFilterRepositoryContract(name: string, make: BanFilterRepositoryFactory): void {
12
+ describe(`BanFilterAdminRepository contract: ${name}`, () => {
13
+ it('round-trips the pattern, the note and who added it', async () => {
14
+ const filters = await make()
15
+ const id = await filters.create({
16
+ type: 'email',
17
+ pattern: '*@blocked.example',
18
+ note: 'the sign-up wave in March',
19
+ createdByUserId: CONTRACT_AUTHOR,
20
+ })
21
+
22
+ const stored = await filters.listForAdmin()
23
+
24
+ expect(stored).toHaveLength(1)
25
+ expect(stored[0]).toMatchObject({
26
+ id,
27
+ type: 'email',
28
+ pattern: '*@blocked.example',
29
+ note: 'the sign-up wave in March',
30
+ createdByUserId: CONTRACT_AUTHOR,
31
+ })
32
+ expect(stored[0]?.createdAt).toBeInstanceOf(Date)
33
+ })
34
+
35
+ it('lists the newest filter first', async () => {
36
+ const filters = await make()
37
+ await filters.create({ type: 'username', pattern: 'first*', createdByUserId: null })
38
+ await filters.create({ type: 'username', pattern: 'second*', createdByUserId: null })
39
+
40
+ expect((await filters.listForAdmin()).map((row) => row.pattern)).toEqual([
41
+ 'second*',
42
+ 'first*',
43
+ ])
44
+ })
45
+
46
+ it('keeps an unwritten note and an unnamed author as null', async () => {
47
+ const filters = await make()
48
+ await filters.create({
49
+ type: 'ip',
50
+ pattern: '192.0.2.*',
51
+ note: ' ',
52
+ createdByUserId: null,
53
+ })
54
+
55
+ expect((await filters.listForAdmin())[0]).toMatchObject({
56
+ note: null,
57
+ createdByUserId: null,
58
+ })
59
+ })
60
+
61
+ it('stores the pattern without the whitespace around it', async () => {
62
+ const filters = await make()
63
+ await filters.create({ type: 'username', pattern: ' spammer* ', createdByUserId: null })
64
+
65
+ expect((await filters.listForAdmin())[0]?.pattern).toBe('spammer*')
66
+ })
67
+
68
+ it('refuses a second filter of the same type and pattern', async () => {
69
+ const filters = await make()
70
+ await filters.create({ type: 'email', pattern: '*@blocked.example', createdByUserId: null })
71
+
72
+ await expect(
73
+ filters.create({ type: 'email', pattern: '*@blocked.example', createdByUserId: null }),
74
+ ).rejects.toThrow(ValidationError)
75
+
76
+ expect(await filters.listForAdmin()).toHaveLength(1)
77
+ })
78
+
79
+ it('holds the same pattern under a different type', async () => {
80
+ const filters = await make()
81
+ await filters.create({ type: 'email', pattern: 'shared*', createdByUserId: null })
82
+
83
+ await expect(
84
+ filters.create({ type: 'username', pattern: 'shared*', createdByUserId: null }),
85
+ ).resolves.toEqual(expect.any(Number))
86
+ })
87
+
88
+ it('refuses an empty pattern before writing anything', async () => {
89
+ const filters = await make()
90
+
91
+ await expect(
92
+ filters.create({ type: 'username', pattern: ' ', createdByUserId: null }),
93
+ ).rejects.toThrow(ValidationError)
94
+
95
+ expect(await filters.listForAdmin()).toHaveLength(0)
96
+ })
97
+
98
+ it('refuses a pattern longer than the cap before writing anything', async () => {
99
+ const filters = await make()
100
+
101
+ await expect(
102
+ filters.create({
103
+ type: 'username',
104
+ pattern: 'a'.repeat(BAN_FILTER_PATTERN_MAX + 1),
105
+ createdByUserId: null,
106
+ }),
107
+ ).rejects.toThrow(ValidationError)
108
+
109
+ expect(await filters.listForAdmin()).toHaveLength(0)
110
+ })
111
+
112
+ it('refuses a pattern with more wildcards than the cap before writing anything', async () => {
113
+ const filters = await make()
114
+
115
+ await expect(
116
+ filters.create({
117
+ type: 'username',
118
+ pattern: `a${'*a'.repeat(BAN_FILTER_WILDCARD_MAX + 1)}`,
119
+ createdByUserId: null,
120
+ }),
121
+ ).rejects.toThrow(ValidationError)
122
+
123
+ expect(await filters.listForAdmin()).toHaveLength(0)
124
+ })
125
+
126
+ it('removes a filter', async () => {
127
+ const filters = await make()
128
+ const id = await filters.create({
129
+ type: 'email',
130
+ pattern: '*@blocked.example',
131
+ createdByUserId: null,
132
+ })
133
+
134
+ await filters.remove(id)
135
+
136
+ expect(await filters.listForAdmin()).toHaveLength(0)
137
+ expect(await filters.listAll()).toHaveLength(0)
138
+ })
139
+
140
+ it('treats removing a filter that is already gone as done', async () => {
141
+ const filters = await make()
142
+ const id = await filters.create({
143
+ type: 'email',
144
+ pattern: '*@blocked.example',
145
+ createdByUserId: null,
146
+ })
147
+
148
+ await filters.remove(id)
149
+
150
+ await expect(filters.remove(id)).resolves.toBeUndefined()
151
+ })
152
+
153
+ it('gives the matcher only what it needs to match on', async () => {
154
+ const filters = await make()
155
+ const id = await filters.create({
156
+ type: 'email',
157
+ pattern: '*@blocked.example',
158
+ note: 'a note the matcher has no use for',
159
+ createdByUserId: CONTRACT_AUTHOR,
160
+ })
161
+
162
+ expect(await filters.listAll()).toEqual([{ id, type: 'email', pattern: '*@blocked.example' }])
163
+ })
164
+ })
165
+ }
package/src/ban-repos.ts CHANGED
@@ -1,13 +1,18 @@
1
- import { and, asc, eq, isNotNull, isNull, lte, sql } from 'drizzle-orm'
2
-
3
- import type {
4
- BanFilter,
5
- BanFilterRepository,
6
- BanFilterType,
7
- BanRecord,
8
- BanRepository,
9
- CreateBanInput,
1
+ import { and, asc, desc, eq, isNotNull, isNull, lte, sql } from 'drizzle-orm'
2
+
3
+ import {
4
+ assertUsableFilter,
5
+ type BanFilter,
6
+ type BanFilterAdminRepository,
7
+ type BanFilterRecord,
8
+ type BanFilterType,
9
+ type BanRecord,
10
+ type BanRepository,
11
+ type CreateBanFilterInput,
12
+ type CreateBanInput,
10
13
  } from '@meith/accounts'
14
+ import { ValidationError } from '@meith/core'
15
+ import { msg } from '@meith/i18n'
11
16
 
12
17
  import type { Database } from './client'
13
18
  import { banFilters, bans, rememberTokens, sessions, users } from './schema'
@@ -133,7 +138,7 @@ export class PostgresBanRepository implements BanRepository {
133
138
  }
134
139
  }
135
140
 
136
- export class PostgresBanFilterRepository implements BanFilterRepository {
141
+ export class PostgresBanFilterRepository implements BanFilterAdminRepository {
137
142
  constructor(private readonly db: Database) {}
138
143
 
139
144
  async listAll(): Promise<readonly BanFilter[]> {
@@ -143,4 +148,45 @@ export class PostgresBanFilterRepository implements BanFilterRepository {
143
148
 
144
149
  return rows.map((r) => ({ id: r.id, type: r.type as BanFilterType, pattern: r.pattern }))
145
150
  }
151
+
152
+ async listForAdmin(): Promise<readonly BanFilterRecord[]> {
153
+ const rows = await this.db
154
+ .select({
155
+ id: banFilters.id,
156
+ type: banFilters.type,
157
+ pattern: banFilters.pattern,
158
+ note: banFilters.note,
159
+ createdByUserId: banFilters.createdByUserId,
160
+ createdAt: banFilters.createdAt,
161
+ })
162
+ .from(banFilters)
163
+ .orderBy(desc(banFilters.id))
164
+
165
+ return rows.map((r) => ({ ...r, type: r.type as BanFilterType }))
166
+ }
167
+
168
+ async create(input: CreateBanFilterInput): Promise<number> {
169
+ assertUsableFilter(input.type, input.pattern)
170
+
171
+ const note = input.note?.trim()
172
+
173
+ const [row] = await this.db
174
+ .insert(banFilters)
175
+ .values({
176
+ type: input.type,
177
+ pattern: input.pattern.trim(),
178
+ note: note === undefined || note === '' ? null : note,
179
+ createdByUserId: input.createdByUserId,
180
+ })
181
+ .onConflictDoNothing({ target: [banFilters.type, banFilters.pattern] })
182
+ .returning({ id: banFilters.id })
183
+
184
+ if (row === undefined) throw new ValidationError(msg('error.accounts.ban-filter-already-held'))
185
+
186
+ return row.id
187
+ }
188
+
189
+ async remove(id: number): Promise<void> {
190
+ await this.db.delete(banFilters).where(eq(banFilters.id, id))
191
+ }
146
192
  }
@@ -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
@@ -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[]
@@ -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
  }
@@ -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
 
@@ -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
@@ -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
 
@@ -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'),
@@ -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
 
@@ -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) {